ktstr 0.23.0

Test harness for Linux process schedulers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
//! MSI-X interrupt delivery for virtio PCI devices.
//!
//! Shared by the virtio-net and virtio-blk PCI facades. The worked examples
//! below use virtio-net's virtqueue layout (RX/TX data queues plus an optional
//! control vq); virtio-blk has a single request queue, so its core emits
//! `IrqSource::Vring { queue: 0 }` for a used-ring publish and
//! `IrqSource::Config` for a NEEDS_RESET / config-change.
//!
//! A device's transport-neutral core signals interrupts by emitting an abstract
//! [`IrqSource`] (a virtqueue used-ring publish or a config-change). On the INTx
//! path the core writes its single `irq_evt` line directly (unchanged for
//! virtio-MMIO and aarch64). When the guest enables MSI-X on the PCI transport,
//! the core instead routes the signal through this module's [`MsixState`], which
//! resolves the source to a guest-assigned vector, gates on the per-vector +
//! function masks, and either fires that vector's irqfd-registered eventfd or
//! records a Pending-Bit-Array bit for replay on unmask.
//!
//! Ownership mirrors cloud-hypervisor's device→`Arc<dyn VirtioInterrupt>`
//! boundary (the device core never sees a vector number, eventfd, or GSI) but
//! adapts it to ktstr's structure: ktstr's PCI facade OWNS the device core (the
//! core is a field of the facade), so an interrupt object injected into the
//! device at activate-time does not fit. Instead the delivery state lives in an
//! `Arc<PiMutex<MsixState>>` SHARED between the core (which holds a clone and
//! calls [`MsixState::signal`] at the trigger site) and the PCI facade (which
//! holds a clone, mutates it from config-space decode, and owns the KVM
//! GSI-route side: the per-vector GSIs and the route owner —
//! `FullIrqchipRouteOwner` on full-irqchip, `IoapicHandle` on split). The
//! gate stays at the trigger site (not a post-hoc drain), matching both
//! cloud-hypervisor (`VirtioInterruptMsix::trigger`) and qemu (`msix_notify`).
//! The mutex is uncontended in practice: every access — facade config writes and
//! core signals alike — is on the vCPU thread under the `PciBus` lock.
//!
//! # Per-virtqueue vectors (EACH policy) with SHARED fallback
//!
//! The device advertises one MSI-X table entry per virtqueue plus one for
//! config (`num_queues + 1`, sized per device — see [`MsixState::new`] — and
//! capped at the host's per-device GSI budget). Linux's `virtio_pci_modern` driver
//! tries three vector policies in order (drivers/virtio/virtio_pci_common.c
//! `vp_find_vqs`): `VP_VQ_VECTOR_POLICY_EACH`, then
//! `VP_VQ_VECTOR_POLICY_SHARED_SLOW`, then `VP_VQ_VECTOR_POLICY_SHARED`.
//!
//! EACH requests one vector for the config-change interrupt plus one for every
//! virtqueue that has a callback (`vp_find_vqs_msix`: `nvectors = 1 +
//! count(vqs with name && callback)`). virtio-net's data virtqueues (RX/TX)
//! each have a callback, so for `N` queue-pairs EACH needs `2N + 1` vectors; the
//! control vq has NO callback, so `vp_find_one_vq_msix` assigns it
//! `VIRTIO_MSI_NO_VECTOR` (it is polled, not interrupt-driven). The device
//! advertises one entry per virtqueue plus config — `num_queues + 1`, which is
//! `2N + 2` under multiqueue (the control vq is present: `num_queues = 2N + 1`)
//! or `2N + 1` for the single-pair default (no control vq: `N = 1`,
//! `num_queues = 2` → 3 vectors). Either way EACH's `2N + 1` request fits:
//! config → vector 0, data vq `i` → vector `i + 1` (`allocated_vectors++` per
//! vq); under multiqueue the control vq's spare table entry is left unassigned,
//! and single-pair is an exact fit. Each data vq thus fires its OWN vector +
//! GSI — the per-node IRQ steering this device exists to exercise.
//!
//! [`MsixState::signal`] resolves an [`IrqSource::Vring`] to that vq's
//! `queue_vectors[queue]` entry, so RX, TX, and distinct queue-pairs deliver
//! independently. When a queue count exceeds the per-device vector budget, EACH
//! (and SHARED_SLOW) request more vectors than the table holds and fail; the
//! driver falls through to SHARED (`nvectors = 2` in `vp_find_vqs_msix`'s
//! `!per_vq_vectors` branch, virtio_pci_common.c): config → vector 0, ALL
//! virtqueues → vector 1 (VP_MSIX_CONFIG_VECTOR / VP_MSIX_VQ_VECTOR,
//! virtio_pci_common.h). The per-queue signal path serves
//! SHARED transparently — every `queue_vectors[]` entry the guest programs
//! resolves to the same vector, so a coalesced VRING signal fires it and the
//! guest's shared-vector handler polls every vq mapped to it. Multiqueue is
//! offered only on this MSI-X transport (`VirtioNet::device_features` gates
//! `VIRTIO_NET_F_MQ` on `msix`), so every queue signal rides the one-shot
//! edge-MSI path.

use vmm_sys_util::eventfd::EventFd;

/// MSI-X table-page vector capacity: the BAR0 MSI-X-table page (one 4 KiB page
/// in the PCI facade) holds `4096 / 16 = 256` 16-byte entries, so the device
/// can advertise at most this many vectors before the table would overflow its
/// page into the PBA page. The PCI facade static-asserts its table-page byte
/// span matches this. This is the page-capacity ceiling; the BINDING runtime
/// cap on a device's advertised vectors is `max_vectors` passed to
/// [`MsixState::new`] (the host's per-device MSI-X GSI budget,
/// `kvm::MSIX_VECTORS_PER_NIC`), which the kvm module const-asserts is `<=` this
/// ceiling. A queue count beyond the cap makes the guest fall back from per-vq
/// (EACH) to SHARED.
pub(crate) const MSIX_TABLE_MAX: usize = 256;

/// Dwords per MSI-X table entry (PCI spec §6.8.2): message address lo, message
/// address hi, message data, vector control.
pub(crate) const MSIX_ENTRY_DWORDS: usize = 4;

/// Dword index of the Vector Control register within a table entry; bit 0 is the
/// per-vector mask.
const VECTOR_CTRL_DWORD: usize = 3;
/// Vector Control bit 0 — the per-vector mask (PCI spec §6.8.2.9).
const VECTOR_CTRL_MASK_BIT: u32 = 0x1;

/// `VIRTIO_MSI_NO_VECTOR` (virtio-v1.2 §4.1.5.1.2): a source whose MSI-X vector
/// register holds this value is not connected to MSI-X. The guest writes it to
/// disable a source's MSI-X delivery, and the device reset value is this.
pub(crate) const NO_VECTOR: u16 = 0xFFFF;

/// An abstract interrupt source the device core emits. The transport resolves it
/// to an INTx line (the core's `irq_evt`) or an MSI-X vector. Mirrors
/// cloud-hypervisor's `VirtioInterruptType::{Queue, Config}`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum IrqSource {
    /// A used-ring publish on virtqueue `queue` (`VIRTIO_MMIO_INT_VRING`). The
    /// queue index selects that vq's guest-assigned MSI-X vector
    /// (`queue_vectors[queue]`) for per-queue IRQ steering; on the INTx path the
    /// device collapses every source onto its single shared line regardless.
    Vring { queue: usize },
    /// A device config-change / NEEDS_RESET (`VIRTIO_MMIO_INT_CONFIG`).
    Config,
}

/// Abstraction over the host's KVM GSI-route programming. Implemented x86-only
/// by `FullIrqchipRouteOwner` (full-irqchip) and `IoapicHandle` (split-irqchip);
/// kept a trait so the (arch-neutral) PCI facade holds
/// `Option<Arc<dyn MsixRouteSink>>` without referencing the x86-only KVM route
/// types. The facade calls this on a vector mask/unmask edge to (re)install or
/// remove the MSI route for a GSI.
pub(crate) trait MsixRouteSink: Send + Sync {
    /// Install (`Some((address_lo, address_hi, data))`) or remove (`None`) the
    /// MSI route for `gsi`, rebuilding the full GSI routing table. Errors are
    /// recorded by the implementation (a failed-install counter surfaced at
    /// teardown via the concrete owner) rather than returned: the caller is a
    /// config-space MMIO write with no error channel back to the guest.
    fn set_route(&self, gsi: u32, msg: Option<(u32, u32, u32)>);
}

/// MSI-X delivery state for one virtio device: the guest-visible table
/// entries + vector assignments + Pending Bit Array, the device-level enable /
/// function-mask, and the per-vector irqfd-registered eventfds. Shared via
/// `Arc<PiMutex<…>>` between the device core (signals) and the PCI facade
/// (configures); see the module doc. The KVM GSI routes and the per-vector GSIs
/// live in the facade, not here — this struct owns only what the *device* needs
/// to fire (eventfds) plus the guest-visible register state.
pub(crate) struct MsixState {
    /// Per-vector irqfd eventfds. Index = MSI-X table entry. `None` until the
    /// host wiring registers the eventfd with KVM and hands it here; the device
    /// writes it to fire the vector. Not part of the guest-visible register
    /// state — the host's delivery handle. Length = `num_vectors` (config +
    /// one per virtqueue), sized at construction.
    eventfds: Vec<Option<EventFd>>,
    /// Guest-visible MSI-X table: `[addr_lo, addr_hi, data, vector_ctrl]` per
    /// entry. Reset value per the PCI spec is masked (vector_ctrl bit 0 = 1).
    /// The address/data dwords are the verbatim MSI message the guest composed
    /// (x86 `__irq_msi_compose_msg`), which the KVM `KVM_IRQ_ROUTING_MSI` route
    /// carries unchanged — so the facade reads them here to build the route.
    /// Length = `num_vectors`, sized at construction.
    table: Vec<[u32; MSIX_ENTRY_DWORDS]>,
    /// Pending Bit Array: one bit per vector, set when a masked vector would
    /// have fired, replayed + cleared on unmask. Byte-addressed to match the
    /// guest-visible PBA register read; `ceil(num_vectors / 8)` bytes, sized at
    /// construction (per-vq MSI-X exceeds the 8 vectors one byte holds).
    pba: Vec<u8>,
    /// Device-level MSI-X Enable (Message Control bit 15). `false` ⇒ the device
    /// delivers via INTx; the core consults [`Self::enabled`] to choose.
    enabled: bool,
    /// Device-level Function Mask (Message Control bit 14): masks ALL vectors
    /// regardless of per-vector mask (PCI spec §6.8.2.9).
    function_mask: bool,
    /// Guest-assigned config-change vector (`CC_MSIX_CONFIG`), `NO_VECTOR` until
    /// the guest programs it.
    config_vector: u16,
    /// Guest-assigned per-queue vectors (`CC_QUEUE_MSIX_VECTOR` per queue),
    /// `NO_VECTOR` until programmed. Length is the device's virtqueue count
    /// (`2 * queue_pairs`, plus the control vq when multiqueue is offered),
    /// fixed at construction. Under EACH each entry holds a distinct vector
    /// (config + one per vq); under SHARED every entry resolves to the same
    /// vector (see the module doc). Either way the vector VALUES stay within
    /// `num_vectors` (the table / PBA / eventfd array length), so only this
    /// per-queue map scales with the queue count, not those arrays.
    queue_vectors: Vec<u16>,
}

impl MsixState {
    /// New state with every vector unprogrammed, every table entry masked (the
    /// PCI reset value), MSI-X disabled. `num_queues` is the device's virtqueue
    /// count — the length of the per-queue vector map ([`Self::queue_vectors`]).
    /// `max_vectors` caps the advertised vector count (the table / PBA / eventfd
    /// array length): the host's per-device MSI-X GSI budget
    /// (`kvm::MSIX_VECTORS_PER_NIC`), itself bounded by the table-page capacity
    /// [`MSIX_TABLE_MAX`].
    pub(crate) fn new(num_queues: usize, max_vectors: usize) -> Self {
        // One MSI-X vector per virtqueue plus one for config/NEEDS_RESET — the
        // table size the guest's EACH policy (vp_find_one_vq_msix) requests for
        // per-queue IRQ steering: config → vector 0, each vq → 1..=num_queues.
        // Capped at `max_vectors` (the host's per-device MSI-X GSI budget, ≤ the
        // BAR0 MSI-X-table page capacity): for a queue count within budget the
        // full per-vq table fits and the guest assigns each vq its own vector;
        // for a larger count the table is too small for EACH, so the guest falls
        // back to SHARED (all vqs → one vector) — the per-queue signal path
        // handles both transparently (in SHARED mode every queue_vectors[] entry
        // resolves to the same vector). This avoids a multi-page table without
        // capping queue_pairs.
        let num_vectors = (num_queues + 1).min(max_vectors);
        let mut table = vec![[0u32; MSIX_ENTRY_DWORDS]; num_vectors];
        for entry in &mut table {
            entry[VECTOR_CTRL_DWORD] = VECTOR_CTRL_MASK_BIT;
        }
        MsixState {
            eventfds: (0..num_vectors).map(|_| None).collect(),
            table,
            pba: vec![0u8; num_vectors.div_ceil(8)],
            enabled: false,
            function_mask: false,
            config_vector: NO_VECTOR,
            queue_vectors: vec![NO_VECTOR; num_queues],
        }
    }

    /// Number of MSI-X vectors the device advertises (`num_queues + 1`, capped at
    /// the `max_vectors` passed to [`Self::new`]): the table size driving the
    /// Message Control Table Size field and the per-vector GSI/eventfd count the
    /// host wiring allocates.
    pub(crate) fn num_vectors(&self) -> usize {
        self.table.len()
    }

    /// True once the guest has set MSI-X Enable. The device core routes through
    /// MSI-X only then; otherwise it uses INTx.
    pub(crate) fn enabled(&self) -> bool {
        self.enabled
    }

    /// Deliver `source`: resolve to the guest-assigned vector, then fire its
    /// eventfd if deliverable, else record a pending bit. Called at the trigger
    /// site from the device core (cloud-hypervisor `VirtioInterruptMsix::trigger`
    /// / qemu `msix_notify`).
    pub(crate) fn signal(&mut self, source: IrqSource) {
        let vector = match source {
            IrqSource::Config => self.config_vector,
            // Per-queue routing: each virtqueue fires its OWN assigned vector
            // (the guest's EACH policy gives every vq a distinct vector). An
            // out-of-range queue index or an unassigned (NO_VECTOR) entry makes
            // fire() a no-op.
            IrqSource::Vring { queue } => {
                self.queue_vectors.get(queue).copied().unwrap_or(NO_VECTOR)
            }
        };
        self.fire(vector);
    }

    /// Fire `vector`'s eventfd if it is connected, enabled, and unmasked; else
    /// record a pending bit for replay on unmask. A `NO_VECTOR` or out-of-range
    /// vector is a no-op (the source is not connected to MSI-X).
    fn fire(&mut self, vector: u16) {
        if vector == NO_VECTOR {
            return;
        }
        let idx = vector as usize;
        if idx >= self.table.len() {
            // Clamped on the write path; defensive here so a stale/hostile
            // vector cannot index out of bounds.
            return;
        }
        if !self.enabled {
            // MSI-X disabled: the core would not have called us; defensive.
            return;
        }
        if self.function_mask || self.vector_masked(idx) {
            // Masked: record pending (PBA bit `idx`); replayed on unmask.
            self.pba[idx / 8] |= 1 << (idx % 8);
            return;
        }
        if let Some(evt) = &self.eventfds[idx]
            && let Err(e) = evt.write(1)
        {
            tracing::warn!(%e, vector = idx, "virtio MSI-X eventfd write failed");
        }
    }

    /// Replay a vector that became deliverable on an unmask edge: if its pending
    /// bit is set and it is now deliverable, fire it once and clear the bit.
    /// Returns whether a delivery occurred. The facade calls this AFTER
    /// installing the GSI route for the unmasked vector, so the replayed write
    /// has a route to deliver through.
    pub(crate) fn replay_pending(&mut self, idx: usize) -> bool {
        if idx >= self.table.len() || self.pba[idx / 8] & (1 << (idx % 8)) == 0 {
            return false;
        }
        if self.function_mask || self.vector_masked(idx) || !self.enabled {
            return false;
        }
        // Clear the pending bit only AFTER a successful eventfd write: a failed
        // write (EBADF on teardown, or the implausible EAGAIN at u64 counter
        // saturation) then leaves the bit set so the interrupt is retried on the
        // next unmask edge — never dropping a pending the guest has not yet
        // observed. (Symmetric with fire(), which records the bit before any
        // write can fail.)
        if let Some(evt) = &self.eventfds[idx] {
            if let Err(e) = evt.write(1) {
                tracing::warn!(%e, vector = idx, "virtio MSI-X pending replay write failed");
                return false;
            }
            self.pba[idx / 8] &= !(1 << (idx % 8));
            return true;
        }
        false
    }

    /// Per-vector mask bit (Vector Control bit 0) for table entry `idx`.
    fn vector_masked(&self, idx: usize) -> bool {
        self.table
            .get(idx)
            .is_none_or(|e| e[VECTOR_CTRL_DWORD] & VECTOR_CTRL_MASK_BIT != 0)
    }

    // ----- facade-driven configuration (config-space decode) -----

    /// Apply a Message Control write (cap register): Enable (bit 15) and
    /// Function Mask (bit 14). The facade reconciles every vector's KVM route
    /// after this call (install the deliverable, remove the rest), so no
    /// prior-state edge detection is needed here.
    pub(crate) fn set_message_control(&mut self, msg_ctrl: u16) {
        self.enabled = msg_ctrl & 0x8000 != 0;
        self.function_mask = msg_ctrl & 0x4000 != 0;
    }

    /// Set the config-change vector (`CC_MSIX_CONFIG`); `vector` is pre-clamped
    /// by the facade to a valid index or `NO_VECTOR`.
    pub(crate) fn set_config_vector(&mut self, vector: u16) {
        self.config_vector = vector;
    }

    /// Read back the config-change vector (the kernel's `vp_modern` aborts if the
    /// read-back differs from the written vector).
    pub(crate) fn config_vector(&self) -> u16 {
        self.config_vector
    }

    /// Set queue `queue`'s vector (`CC_QUEUE_MSIX_VECTOR`); pre-clamped by the
    /// facade. Out-of-range `queue` is dropped (hostile `queue_select`).
    pub(crate) fn set_queue_vector(&mut self, queue: usize, vector: u16) {
        if let Some(slot) = self.queue_vectors.get_mut(queue) {
            *slot = vector;
        }
    }

    /// Read back queue `queue`'s vector, `NO_VECTOR` for an out-of-range queue.
    pub(crate) fn queue_vector(&self, queue: usize) -> u16 {
        self.queue_vectors.get(queue).copied().unwrap_or(NO_VECTOR)
    }

    /// Virtio reset: restore the per-queue + config vector assignments to their
    /// reset value (`NO_VECTOR`, virtio-v1.2 §4.1.4.3). Does NOT touch the
    /// PCI-level MSI-X cap (enable, function mask, table, PBA, registered
    /// eventfds) — those reset only on PCI/FLR reset, not a virtio status reset.
    pub(crate) fn reset_virtio_assignments(&mut self) {
        self.config_vector = NO_VECTOR;
        // Keep the per-queue map's length (the device's queue count is fixed
        // at construction); reset every entry to unassigned.
        self.queue_vectors.fill(NO_VECTOR);
    }

    /// Read a table dword `(entry, dword)`; 0 for out-of-range (a conformant
    /// guest never reads past the advertised table; the bound keeps a hostile
    /// read in-bounds).
    pub(crate) fn table_dword(&self, entry: usize, dword: usize) -> u32 {
        self.table
            .get(entry)
            .and_then(|e| e.get(dword))
            .copied()
            .unwrap_or(0)
    }

    /// Write a table dword `(entry, dword)`. Returns `true` iff this write
    /// cleared the Vector Control mask bit (a mask→unmask edge on `entry`), so
    /// the facade installs the route + replays any pending bit. A write that
    /// SETs the mask bit, or any other dword, returns `false`. Out-of-range is
    /// dropped (the bound keeps a hostile write in-bounds).
    pub(crate) fn write_table_dword(&mut self, entry: usize, dword: usize, val: u32) -> bool {
        let Some(slot) = self.table.get_mut(entry).and_then(|e| e.get_mut(dword)) else {
            return false;
        };
        // Only the Vector Control dword carries the mask bit, so only it can
        // produce a mask→unmask edge; addr/data dwords never change mask state.
        if dword == VECTOR_CTRL_DWORD {
            let was_masked = *slot & VECTOR_CTRL_MASK_BIT != 0;
            *slot = val;
            let now_masked = val & VECTOR_CTRL_MASK_BIT != 0;
            was_masked && !now_masked
        } else {
            *slot = val;
            false
        }
    }

    /// True iff table entry `idx` is currently unmasked (per-vector AND function
    /// mask both clear). The facade uses this to decide which vectors to route
    /// on a function-mask / enable edge.
    pub(crate) fn vector_unmasked(&self, idx: usize) -> bool {
        !self.function_mask && !self.vector_masked(idx)
    }

    /// The MSI message `(address_lo, address_hi, data)` programmed into table
    /// entry `idx` — the verbatim route the facade installs via
    /// `KVM_IRQ_ROUTING_MSI`. `None` for an out-of-range entry.
    pub(crate) fn msi_message(&self, idx: usize) -> Option<(u32, u32, u32)> {
        self.table.get(idx).map(|e| (e[0], e[1], e[2]))
    }

    /// Install the irqfd eventfd for vector `idx` (the host wiring registered it
    /// with KVM). Returns the prior eventfd, if any.
    pub(crate) fn set_eventfd(&mut self, idx: usize, evt: EventFd) -> Option<EventFd> {
        let slot = self.eventfds.get_mut(idx)?;
        slot.replace(evt)
    }

    /// The Pending Bit Array byte at `byte_off` (8 vectors per byte) for the
    /// guest-visible PBA register read. 0 for an out-of-range offset (a
    /// conformant guest reads only within the advertised PBA).
    pub(crate) fn pba_byte(&self, byte_off: usize) -> u8 {
        self.pba.get(byte_off).copied().unwrap_or(0)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::os::fd::AsRawFd;

    /// The virtio-net single-pair baseline (1 RX + 1 TX, no control vq) — the
    /// example queue layout these shared-MSI-X tests use. Production sizes
    /// `queue_vectors` from the per-device queue count passed to
    /// [`MsixState::new`], not this constant.
    const NUM_QUEUES: usize = 2;

    /// Single-pair MSI-X vector count under the EACH policy: config + one per
    /// virtqueue (RX vq0 + TX vq1), no control vq. `MsixState::new(NUM_QUEUES,
    /// _)` sizes the table to exactly this.
    const SINGLE_PAIR_VECTORS: usize = NUM_QUEUES + 1;

    /// Drain `evt`'s counter, returning how many writes it had accumulated. The
    /// eventfd is counter-mode (no EFD_SEMAPHORE), so one read returns the sum.
    fn drain(evt: &EventFd) -> u64 {
        evt.read().unwrap_or(0)
    }

    /// A single-pair state with one registered eventfd per vector, MSI-X
    /// enabled, the EACH assignment (config → 0, RX vq0 → 1, TX vq1 → 2 —
    /// DISTINCT per vq), every vector unmasked. `evts[v]` is the readable end of
    /// vector `v`'s eventfd.
    fn enabled_state() -> (MsixState, [EventFd; SINGLE_PAIR_VECTORS]) {
        let mut s = MsixState::new(NUM_QUEUES, MSIX_TABLE_MAX);
        let evts: [EventFd; SINGLE_PAIR_VECTORS] =
            std::array::from_fn(|_| EventFd::new(libc::EFD_NONBLOCK).unwrap());
        // Hand the state a dup of each fd so the test keeps its own readable end.
        for (v, evt) in evts.iter().enumerate() {
            s.set_eventfd(v, evt.try_clone().unwrap());
        }
        // EACH assignment: config → vector 0, data vq i → its OWN vector i + 1.
        s.set_config_vector(0);
        for q in 0..NUM_QUEUES {
            s.set_queue_vector(q, (q + 1) as u16);
        }
        // Program + unmask every table entry (addr/data then clear mask bit).
        for e in 0..SINGLE_PAIR_VECTORS {
            s.write_table_dword(e, 0, 0xFEE0_0000);
            s.write_table_dword(e, 2, 0x4000 + e as u32);
            assert!(s.write_table_dword(e, VECTOR_CTRL_DWORD, 0)); // unmask edge
        }
        s.set_message_control(0x8000); // Enable, function-mask clear
        (s, evts)
    }

    #[test]
    fn reset_state_is_masked_and_disabled() {
        let s = MsixState::new(NUM_QUEUES, MSIX_TABLE_MAX);
        assert_eq!(
            s.num_vectors(),
            SINGLE_PAIR_VECTORS,
            "config + one vector per virtqueue"
        );
        assert!(!s.enabled());
        assert_eq!(s.config_vector(), NO_VECTOR);
        for q in 0..NUM_QUEUES {
            assert_eq!(s.queue_vector(q), NO_VECTOR);
        }
        // Every table entry resets masked (vector control bit0 = 1).
        for e in 0..SINGLE_PAIR_VECTORS {
            assert_eq!(s.table_dword(e, VECTOR_CTRL_DWORD), VECTOR_CTRL_MASK_BIT);
            assert!(!s.vector_unmasked(e));
        }
        assert_eq!(s.pba_byte(0), 0);
    }

    #[test]
    fn num_vectors_is_num_queues_plus_one() {
        // Eight virtqueues (e.g. four queue-pairs, no control vq) → nine vectors.
        let s = MsixState::new(8, MSIX_TABLE_MAX);
        assert_eq!(s.num_vectors(), 9);
    }

    #[test]
    fn vector_count_caps_at_max_vectors() {
        // A queue count whose num_queues + 1 exceeds the cap: the table is sized
        // to the cap (the guest then falls back to SHARED across the queues),
        // while the per-queue map still tracks every queue.
        let s = MsixState::new(1000, 8);
        assert_eq!(
            s.num_vectors(),
            8,
            "advertised vectors capped at max_vectors"
        );
        assert_eq!(s.queue_vector(999), NO_VECTOR, "every queue still tracked");
    }

    #[test]
    fn per_queue_vectors_deliver_independently() {
        let (mut s, evts) = enabled_state();
        // Queue 0 (RX) fires its OWN vector 1; config + queue 1 untouched.
        s.signal(IrqSource::Vring { queue: 0 });
        assert_eq!(drain(&evts[1]), 1, "queue 0 fires its own vector 1");
        assert_eq!(drain(&evts[0]), 0, "config vector untouched");
        assert_eq!(drain(&evts[2]), 0, "queue 1's vector untouched");
        // Queue 1 (TX) fires its OWN DISTINCT vector 2.
        s.signal(IrqSource::Vring { queue: 1 });
        assert_eq!(drain(&evts[2]), 1, "queue 1 fires its own vector 2");
        assert_eq!(drain(&evts[1]), 0, "queue 0's vector untouched");
        // Config → vector 0.
        s.signal(IrqSource::Config);
        assert_eq!(drain(&evts[0]), 1, "config fires vector 0");
        assert_eq!(s.pba_byte(0), 0, "no pending while unmasked");
    }

    #[test]
    fn multi_pair_queues_deliver_to_distinct_vectors() {
        // Two queue-pairs + control vq: num_queues = 2*2 + 1 = 5, num_vectors = 6.
        // EACH assigns config→0 and each data vq its OWN vector (RX0→1, TX0→2,
        // RX1→3, TX1→4); the control vq (queue 4) has no callback → NO_VECTOR.
        // Pins the pair>0 queue-index→vector mapping (queue_vectors[2],[3] →
        // vectors 3,4) that the single-pair tests never exercise — the path an
        // off-by-one in per-pair indexing would slip through.
        let mut s = MsixState::new(5, MSIX_TABLE_MAX);
        assert_eq!(
            s.num_vectors(),
            6,
            "config + one vector per vq (cvq slot spare)"
        );
        // One eventfd per data vector (0..=4); vector 5 (the cvq spare) is unused.
        let evts: [EventFd; 5] = std::array::from_fn(|_| EventFd::new(libc::EFD_NONBLOCK).unwrap());
        for (v, evt) in evts.iter().enumerate() {
            s.set_eventfd(v, evt.try_clone().unwrap());
        }
        s.set_config_vector(0);
        // EACH: data vq i → vector i + 1; the control vq (queue 4) stays NO_VECTOR.
        for q in 0..4 {
            s.set_queue_vector(q, (q + 1) as u16);
        }
        for e in 0..5 {
            s.write_table_dword(e, 0, 0xFEE0_0000);
            s.write_table_dword(e, 2, 0x4000 + e as u32);
            assert!(s.write_table_dword(e, VECTOR_CTRL_DWORD, 0));
        }
        s.set_message_control(0x8000); // Enable

        // Pair 1's RX (queue 2) fires ONLY vector 3 — distinct from pair 0.
        s.signal(IrqSource::Vring { queue: 2 });
        assert_eq!(drain(&evts[3]), 1, "queue 2 (RX pair 1) fires vector 3");
        for (v, evt) in evts.iter().enumerate() {
            if v != 3 {
                assert_eq!(drain(evt), 0, "queue 2 fired only vector 3, not {v}");
            }
        }
        // Pair 1's TX (queue 3) fires ONLY vector 4.
        s.signal(IrqSource::Vring { queue: 3 });
        assert_eq!(drain(&evts[4]), 1, "queue 3 (TX pair 1) fires vector 4");
        for (v, evt) in evts.iter().enumerate() {
            if v != 4 {
                assert_eq!(drain(evt), 0, "queue 3 fired only vector 4, not {v}");
            }
        }
        // Pair 0 still maps to its OWN vectors 1,2 (not pair 1's).
        s.signal(IrqSource::Vring { queue: 0 });
        assert_eq!(drain(&evts[1]), 1, "queue 0 (RX pair 0) fires vector 1");
        s.signal(IrqSource::Vring { queue: 1 });
        assert_eq!(drain(&evts[2]), 1, "queue 1 (TX pair 0) fires vector 2");
        // The control vq (queue 4) has NO_VECTOR → no-op (polled, not IRQ-driven).
        s.signal(IrqSource::Vring { queue: 4 });
        for (v, evt) in evts.iter().enumerate() {
            assert_eq!(drain(evt), 0, "control vq delivers no MSI-X (vector {v})");
        }
        assert_eq!(s.pba_byte(0), 0, "all delivered live, nothing pending");
    }

    #[test]
    fn masked_vector_records_pending_then_replays() {
        let (mut s, evts) = enabled_state();
        // Re-mask queue 0's vector (table entry 1, vector control bit0 = 1).
        assert!(!s.write_table_dword(1, VECTOR_CTRL_DWORD, VECTOR_CTRL_MASK_BIT));
        s.signal(IrqSource::Vring { queue: 0 });
        assert_eq!(drain(&evts[1]), 0, "masked vector does not fire");
        assert_eq!(
            s.pba_byte(0) & (1 << 1),
            1 << 1,
            "pending bit set for vector 1"
        );
        // Unmask → replay fires once, pending clears.
        let unmasked = s.write_table_dword(1, VECTOR_CTRL_DWORD, 0);
        assert!(unmasked, "clearing mask bit is an unmask edge");
        assert!(s.replay_pending(1), "pending replays on unmask");
        assert_eq!(drain(&evts[1]), 1, "replay delivered exactly once");
        assert_eq!(s.pba_byte(0) & (1 << 1), 0, "pending cleared after replay");
        assert!(!s.replay_pending(1), "no double replay");
    }

    #[test]
    fn function_mask_suppresses_all_vectors() {
        let (mut s, evts) = enabled_state();
        s.set_message_control(0x8000 | 0x4000); // Enable + Function Mask
        s.signal(IrqSource::Vring { queue: 0 });
        s.signal(IrqSource::Vring { queue: 1 });
        s.signal(IrqSource::Config);
        for (v, evt) in evts.iter().enumerate() {
            assert_eq!(drain(evt), 0, "vector {v} suppressed under function mask");
        }
        assert_eq!(
            s.pba_byte(0),
            (1 << 0) | (1 << 1) | (1 << 2),
            "config + both queue vectors pending under function mask"
        );
    }

    #[test]
    fn no_vector_assignment_is_silent() {
        let (mut s, evts) = enabled_state();
        // Disconnect every queue vector.
        for q in 0..NUM_QUEUES {
            s.set_queue_vector(q, NO_VECTOR);
        }
        s.signal(IrqSource::Vring { queue: 0 });
        s.signal(IrqSource::Vring { queue: 1 });
        assert_eq!(drain(&evts[1]), 0, "unassigned queue 0 delivers nothing");
        assert_eq!(drain(&evts[2]), 0, "unassigned queue 1 delivers nothing");
        assert_eq!(s.pba_byte(0), 0, "and records no pending");
    }

    #[test]
    fn disabled_state_does_not_fire() {
        let (mut s, evts) = enabled_state();
        s.set_message_control(0); // clear Enable
        assert!(!s.enabled());
        s.signal(IrqSource::Vring { queue: 0 });
        assert_eq!(drain(&evts[1]), 0);
        assert_eq!(s.pba_byte(0), 0);
    }

    #[test]
    fn msi_message_reads_programmed_dwords() {
        let (s, _evts) = enabled_state();
        assert_eq!(s.msi_message(0), Some((0xFEE0_0000, 0, 0x4000)));
        assert_eq!(s.msi_message(1), Some((0xFEE0_0000, 0, 0x4001)));
        assert_eq!(s.msi_message(2), Some((0xFEE0_0000, 0, 0x4002)));
        assert_eq!(s.msi_message(SINGLE_PAIR_VECTORS), None, "out of range");
    }

    #[test]
    fn out_of_range_vector_is_inert() {
        let mut s = MsixState::new(NUM_QUEUES, MSIX_TABLE_MAX);
        s.set_message_control(0x8000);
        s.set_config_vector(99); // would be clamped by the facade; defensive here
        s.signal(IrqSource::Config);
        assert_eq!(
            s.pba_byte(0),
            0,
            "out-of-range vector neither fires nor pends"
        );
    }

    #[test]
    fn eventfd_replace_returns_prior() {
        let mut s = MsixState::new(NUM_QUEUES, MSIX_TABLE_MAX);
        let a = EventFd::new(libc::EFD_NONBLOCK).unwrap();
        let b = EventFd::new(libc::EFD_NONBLOCK).unwrap();
        let a_raw = a.as_raw_fd();
        assert!(s.set_eventfd(0, a).is_none(), "first install has no prior");
        let prior = s.set_eventfd(0, b).expect("second install returns prior");
        assert_eq!(prior.as_raw_fd(), a_raw, "prior eventfd returned");
        assert!(
            s.set_eventfd(SINGLE_PAIR_VECTORS, EventFd::new(0).unwrap())
                .is_none(),
            "OOB index"
        );
    }
}