dvb_ci_runtime/linux.rs
1//! Linux `/dev/dvb/adapterN/caM` [`CaDevice`] implementation (the `linux`
2//! feature).
3//!
4//! This is the one place the crate uses `unsafe` — the DVB CA ioctls
5//! (`CA_RESET`, `CA_GET_SLOT_INFO`) via `libc`. The ioctl request numbers are
6//! computed from the standard Linux `_IOC` encoding (Documentation/userspace-api
7//! + `include/uapi/linux/dvb/ca.h`), not hard-coded magic.
8//!
9//! Runtime behaviour requires a real DVB card with a CI slot; it is
10//! compile-checked in CI but exercised only on hardware.
11#![allow(unsafe_code)]
12
13use std::fs::{File, OpenOptions};
14use std::io::{self, Read, Write};
15use std::os::unix::fs::OpenOptionsExt;
16use std::os::unix::io::{AsFd, AsRawFd};
17use std::path::Path;
18use std::time::Duration;
19
20use rustix::event::{Nsecs, PollFd, PollFlags, Timespec};
21
22use crate::dataplane::{CiDataDevice, TS_PACKET_LEN};
23use crate::device::{CaDevice, SlotInfo};
24
25/// Poll a file descriptor for readability up to `timeout`.
26///
27/// `EINTR` surfaces as an `io::Error` of kind `Interrupted`, exactly as it did
28/// with `libc::poll` on `main` (which also returned `last_os_error()` on `-1`
29/// and did not retry). Nothing retries it further up either: the driver pump
30/// does `self.device.poll(timeout)?`. A stray signal therefore still aborts
31/// one pump call, unchanged by this migration. Only `POLLIN` counts as
32/// readable; `POLLHUP`/`POLLERR` alone report `false`, as before. Unlike the
33/// old millisecond truncation the timeout keeps its sub-millisecond part.
34// `Nsecs` is `i64` on rustix's linux_raw backend (where `From<u32>` would do) but
35// `c_long` (`i32`) under its libc backend on 32-bit targets, where only
36// `TryFrom` exists; keep the portable form.
37#[allow(clippy::unnecessary_fallible_conversions)]
38fn poll_readable(fd: &impl AsFd, timeout: Duration) -> io::Result<bool> {
39 let mut fds = [PollFd::new(fd, PollFlags::IN)];
40 let ts = Timespec {
41 tv_sec: i64::try_from(timeout.as_secs()).unwrap_or(i64::MAX),
42 tv_nsec: Nsecs::try_from(timeout.subsec_nanos()).unwrap_or(0),
43 };
44 rustix::event::poll(&mut fds, Some(&ts))?;
45 Ok(fds[0].revents().contains(PollFlags::IN))
46}
47
48// --- Linux _IOC ioctl encoding (uapi/asm-generic/ioctl.h) ------------------
49const IOC_NRBITS: u32 = 8;
50const IOC_TYPEBITS: u32 = 8;
51const IOC_SIZEBITS: u32 = 14;
52const IOC_NRSHIFT: u32 = 0;
53const IOC_TYPESHIFT: u32 = IOC_NRSHIFT + IOC_NRBITS;
54const IOC_SIZESHIFT: u32 = IOC_TYPESHIFT + IOC_TYPEBITS;
55const IOC_DIRSHIFT: u32 = IOC_SIZESHIFT + IOC_SIZEBITS;
56const IOC_NONE: u32 = 0;
57const IOC_READ: u32 = 2;
58
59const fn ioc(dir: u32, typ: u32, nr: u32, size: u32) -> u64 {
60 ((dir << IOC_DIRSHIFT) | (typ << IOC_TYPESHIFT) | (nr << IOC_NRSHIFT) | (size << IOC_SIZESHIFT))
61 as u64
62}
63
64// DVB CA device (uapi/linux/dvb/ca.h): magic 'o', ca_slot_info, flags bit.
65const DVB_CA_MAGIC: u32 = b'o' as u32;
66const CA_RESET: u64 = ioc(IOC_NONE, DVB_CA_MAGIC, 128, 0);
67const CA_GET_SLOT_INFO: u64 = ioc(
68 IOC_READ,
69 DVB_CA_MAGIC,
70 130,
71 core::mem::size_of::<CaSlotInfo>() as u32,
72);
73/// `CA_CI_MODULE_PRESENT` — a module (or card) is physically inserted in the
74/// slot (uapi `linux/dvb/ca.h` `ca_slot_info.flags`, bit 0).
75const CA_CI_MODULE_PRESENT: u32 = 1;
76/// `CA_CI_MODULE_READY` — the inserted module has completed its own init and
77/// is usable (uapi `linux/dvb/ca.h` `ca_slot_info.flags`, bit 1). Distinct
78/// from `CA_CI_MODULE_PRESENT`: a module can be present but not yet ready
79/// briefly after insertion.
80const CA_CI_MODULE_READY: u32 = 2;
81
82#[repr(C)]
83struct CaSlotInfo {
84 num: i32,
85 typ: i32,
86 flags: u32,
87}
88
89/// Settle time after `CA_RESET` before the module is usable. The DD/cxd2099
90/// (and others) only (re)initialise the slot a couple of seconds after reset;
91/// `Create_T_C` sent too early is ignored. 3s is the value validated live
92/// against a DD Octopus cxd2099 + AlphaCrypt module (2s intermittently raced the
93/// module's resource-manager open).
94const RESET_SETTLE: Duration = Duration::from_millis(3000);
95
96/// A [`CaDevice`] backed by a Linux DVB CA character device.
97///
98/// The kernel `dvb_ca_en50221` character device carries a 2-byte link header on
99/// every read/write — `[slot_id, connection_id, <TPDU>]`. This type adds/strips
100/// that header, so the sans-IO transport deals in bare TPDUs. (Writing a raw
101/// TPDU without the header is rejected `EINVAL` by the driver.)
102#[derive(Debug)]
103pub struct LinuxCaDevice {
104 file: File,
105 slot: u8,
106}
107
108impl LinuxCaDevice {
109 /// Open `/dev/dvb/adapter{adapter}/ca{ca}` (slot 0).
110 pub fn open(adapter: u32, ca: u32) -> io::Result<Self> {
111 let path = format!("/dev/dvb/adapter{adapter}/ca{ca}");
112 let file = OpenOptions::new().read(true).write(true).open(path)?;
113 Ok(Self { file, slot: 0 })
114 }
115
116 /// Wrap an already-open CA device file for `slot`.
117 #[must_use]
118 pub fn from_file(file: File, slot: u8) -> Self {
119 Self { file, slot }
120 }
121
122 /// The `connection_id` for a TPDU = its `t_c_id`, which follows the tag +
123 /// `length_field`. Falls back to 1 (the single connection) if unparseable.
124 fn connection_id(tpdu: &[u8]) -> u8 {
125 dvb_ci::length::decode(tpdu.get(1..).unwrap_or(&[]))
126 .ok()
127 .and_then(|(_, hdr)| tpdu.get(1 + hdr).copied())
128 .unwrap_or(1)
129 }
130}
131
132/// Largest legal kernel frame `[slot, connection_id, <TPDU>]` this device can
133/// deliver: the 2-byte link header, plus the largest TPDU EN 50221 Table 1
134/// can encode: `tpdu_tag`(1), `length_field`(up to 3 bytes total, its
135/// maximum-value long form: size_indicator plus 2 length bytes), and the
136/// `length_field`'s own max value, 65535, of `t_c_id` plus data (§7
137/// semantics: "length_field codes the length of all following fields", i.e.
138/// t_c_id + data — see `dvb-ci/docs/en_50221/apdu-coding.md`). r10-W-2: a
139/// 4096-byte scratch buffer silently truncated any larger TPDU and returned
140/// `Ok` with the truncated bytes as if they were the whole thing.
141const MAX_CA_FRAME: usize = 2 + 1 + 3 + 65_535;
142
143impl CaDevice for LinuxCaDevice {
144 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
145 // Read one kernel frame `[slot, connection_id, <TPDU>]` into a scratch
146 // buffer and hand the bare TPDU up. `poll` gates this, so it won't block;
147 // `WouldBlock` is reported as "no data".
148 let mut frame = [0u8; MAX_CA_FRAME];
149 let n = match self.file.read(&mut frame) {
150 Ok(n) => n,
151 Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(0),
152 Err(e) => return Err(e),
153 };
154 if n == frame.len() {
155 // A `read()` reports only how many bytes it actually copied,
156 // never "there was more" — a full buffer means we cannot tell
157 // whether the real frame was exactly this size or the driver
158 // handed us a larger one truncated to fit. Since `MAX_CA_FRAME`
159 // already covers every legal TPDU, treat a full read as a
160 // truncated (and therefore corrupt) frame rather than silently
161 // returning it as complete.
162 return Err(io::Error::new(
163 io::ErrorKind::InvalidData,
164 "CA device frame filled the maximum-size scratch buffer; \
165 it may have been truncated",
166 ));
167 }
168 // Strip the 2-byte link header; anything shorter has no TPDU.
169 let tpdu = frame.get(2..n).unwrap_or(&[]);
170 let copy = tpdu.len().min(buf.len());
171 buf[..copy].copy_from_slice(&tpdu[..copy]);
172 Ok(copy)
173 }
174
175 fn write(&mut self, buf: &[u8]) -> io::Result<()> {
176 // Prepend the `[slot, connection_id]` link header the driver expects.
177 let mut frame = Vec::with_capacity(buf.len() + 2);
178 frame.push(self.slot);
179 frame.push(Self::connection_id(buf));
180 frame.extend_from_slice(buf);
181 self.file.write_all(&frame)
182 }
183
184 fn reset(&mut self) -> io::Result<()> {
185 // r10-W-1: `libc::ioctl`'s request parameter is `libc::Ioctl`, which
186 // is `c_ulong` on glibc but `c_int` on musl/uclibc/Android — a
187 // hard-coded `as libc::c_ulong` fails to compile on those targets.
188 // `libc::Ioctl` picks the right width per target; the encoded
189 // request always fits in 32 bits, so the narrowing cast on
190 // musl/Android is lossless.
191 // SAFETY: CA_RESET takes no argument; fd is a valid open CA device.
192 let r = unsafe { libc::ioctl(self.file.as_raw_fd(), CA_RESET as libc::Ioctl) };
193 if r < 0 {
194 return Err(io::Error::last_os_error());
195 }
196 // The module needs a moment to re-initialise before Create_T_C.
197 std::thread::sleep(RESET_SETTLE);
198 Ok(())
199 }
200
201 fn slot_info(&mut self) -> io::Result<SlotInfo> {
202 let mut si = CaSlotInfo {
203 num: i32::from(self.slot),
204 typ: 0,
205 flags: 0,
206 };
207 // SAFETY: CA_GET_SLOT_INFO writes a ca_slot_info; `si` is exactly that
208 // struct and outlives the call; fd is a valid open CA device.
209 let r = unsafe {
210 libc::ioctl(
211 self.file.as_raw_fd(),
212 CA_GET_SLOT_INFO as libc::Ioctl,
213 &mut si as *mut CaSlotInfo,
214 )
215 };
216 if r < 0 {
217 let err = io::Error::last_os_error();
218 // r10-W-3: only fall back for the specific errno the "doesn't
219 // implement CA_GET_SLOT_INFO" drivers (DD/cxd2099) return —
220 // EINVAL, or ENOTTY for a device node that doesn't support the
221 // ioctl at all; presence then shows via the TPDU handshake
222 // instead, so assume present+ready. Any OTHER error (EIO,
223 // ENODEV, EBADF, …) is a real fault and must be propagated, not
224 // masked as "the module is present and ready".
225 return match err.raw_os_error() {
226 Some(libc::EINVAL) | Some(libc::ENOTTY) => Ok(SlotInfo {
227 num: self.slot,
228 module_ready: true,
229 module_present: true,
230 }),
231 _ => Err(err),
232 };
233 }
234 Ok(SlotInfo {
235 num: si.num as u8,
236 module_ready: si.flags & CA_CI_MODULE_READY != 0,
237 module_present: si.flags & CA_CI_MODULE_PRESENT != 0,
238 })
239 }
240
241 fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
242 poll_readable(&self.file, timeout)
243 }
244}
245
246/// A [`CiDataDevice`] backed by a Linux DVB CI TS data-plane device
247/// (`/dev/dvb/adapterN/ciM`). The host writes scrambled TS and reads the
248/// descrambled TS back; I/O is in whole 188-byte packets.
249#[derive(Debug)]
250pub struct LinuxCiDataDevice {
251 file: File,
252}
253
254impl LinuxCiDataDevice {
255 /// Open `path` **non-blocking** (`O_NONBLOCK`) as a CI data-plane device.
256 ///
257 /// # Why non-blocking (#1066)
258 /// The kernel `ts_read()` on the real device sleeps
259 /// (`wait_event_interruptible`) until >=188 bytes are available; it never
260 /// returns `0` to signal "no more data right now" the way a mock's queue
261 /// does. [`CaDescrambler::feed_ts`](crate::descrambler::CaDescrambler::feed_ts)
262 /// loops `read` until it sees `0` to drain whatever the CAM has already
263 /// produced — on a *blocking* fd, the first call that drains the queue
264 /// blocks forever on the next read instead of returning, and the caller
265 /// can never feed more TS to unblock it (a permanent hang). Opening
266 /// `O_NONBLOCK` makes an empty read return `EWOULDBLOCK` immediately,
267 /// which [`read`](Self::read) maps to `Ok(0)` — the same "no more data"
268 /// signal the mock already gives, so the drain loop terminates. See
269 /// `tests::read_on_empty_device_returns_immediately_not_blocking` below,
270 /// which drives this exact function over a real blocking-capable FIFO.
271 fn open_path(path: &Path) -> io::Result<Self> {
272 let file = OpenOptions::new()
273 .read(true)
274 .write(true)
275 .custom_flags(libc::O_NONBLOCK)
276 .open(path)?;
277 Ok(Self { file })
278 }
279
280 /// Open `/dev/dvb/adapter{adapter}/ci{ci}` — see `open_path`.
281 pub fn open(adapter: u32, ci: u32) -> io::Result<Self> {
282 let path = format!("/dev/dvb/adapter{adapter}/ci{ci}");
283 Self::open_path(Path::new(&path))
284 }
285
286 /// Wrap an already-open CI data-plane device file. The caller is
287 /// responsible for having opened it `O_NONBLOCK` — see
288 /// `open_path` for why a blocking fd here hangs
289 /// `feed_ts` (#1066).
290 #[must_use]
291 pub fn from_file(file: File) -> Self {
292 Self { file }
293 }
294}
295
296impl CiDataDevice for LinuxCiDataDevice {
297 fn write(&mut self, ts: &[u8]) -> io::Result<()> {
298 if !ts.len().is_multiple_of(TS_PACKET_LEN) {
299 return Err(io::Error::new(
300 io::ErrorKind::InvalidInput,
301 "write not a multiple of 188 bytes",
302 ));
303 }
304 self.file.write_all(ts)
305 }
306
307 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
308 if !buf.len().is_multiple_of(TS_PACKET_LEN) {
309 return Err(io::Error::new(
310 io::ErrorKind::InvalidInput,
311 "read buffer not a multiple of 188 bytes",
312 ));
313 }
314 match self.file.read(buf) {
315 Ok(n) => Ok(n),
316 Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(0),
317 Err(e) => Err(e),
318 }
319 }
320
321 fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
322 poll_readable(&self.file, timeout)
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use std::ffi::CString;
330 use std::sync::mpsc;
331
332 /// Create a fresh FIFO (named pipe) under the OS temp dir and return its
333 /// path. `mkfifo` + a FIFO's blocking-read semantics are POSIX/Linux —
334 /// matching why this whole module compiles only under
335 /// `target_os = "linux"` (crate doc) — and, unlike a Unix socketpair
336 /// wrapped in a test-defined `CiDataDevice` (flagged in review as not
337 /// biting), opening the FIFO through `LinuxCiDataDevice::open_path`
338 /// drives the actual production `open`/`read` code under test.
339 fn make_fifo() -> std::path::PathBuf {
340 let path = std::env::temp_dir().join(format!(
341 "dvb-ci-runtime-c1-1066-{}-{}",
342 std::process::id(),
343 std::time::SystemTime::now()
344 .duration_since(std::time::UNIX_EPOCH)
345 .expect("system clock before UNIX_EPOCH")
346 .as_nanos()
347 ));
348 let c_path =
349 CString::new(path.to_string_lossy().into_owned()).expect("path has no interior NUL");
350 // SAFETY: `c_path` is a valid NUL-terminated C string for the
351 // duration of the call; `0o600` is a plain permission mode — no
352 // aliasing or lifetime hazard.
353 let r = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
354 assert_eq!(r, 0, "mkfifo failed: {}", io::Error::last_os_error());
355 path
356 }
357
358 /// #1066: `open_path` must open `O_NONBLOCK`, so an empty read
359 /// returns `Ok(0)` promptly instead of blocking forever — the real bug
360 /// was `CaDescrambler::feed_ts`'s drain loop hanging on its second
361 /// `read`. A FIFO opened `O_RDWR` blocks on an empty read exactly like
362 /// the real `ciM` character device unless `O_NONBLOCK` is set, so this
363 /// is a faithful (if not identical) stand-in, unlike a socketpair with
364 /// its own test-defined `CiDataDevice` impl, which never calls the
365 /// production `open`/`read` at all.
366 ///
367 /// Removing `.custom_flags(libc::O_NONBLOCK)` from `open_path` makes the
368 /// first `recv_timeout` below time out. Verified by hand: with that line
369 /// removed, `cargo test -p dvb-ci-runtime --all-features --locked
370 /// --target x86_64-unknown-linux-gnu` cannot be *run* on macOS at all
371 /// (this module only exists under `target_os = "linux"`, and macOS
372 /// cannot execute a Linux binary), so the removal was checked instead
373 /// with `cargo test -p dvb-ci-runtime --all-features --locked --target
374 /// x86_64-unknown-linux-gnu --no-run` (compiles either way) plus the
375 /// cross-clippy command from CLAUDE.md (lints clean either way — clippy
376 /// cannot see that the missing flag is a bug); real execution proof of
377 /// this specific assertion is Linux CI, which runs this test on every
378 /// push.
379 #[test]
380 fn read_on_empty_device_returns_immediately_not_blocking() {
381 let path = make_fifo();
382 let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
383 let mut writer = OpenOptions::new()
384 .write(true)
385 .open(&path)
386 .expect("open a second, writer-only handle on the same FIFO");
387
388 let (tx, rx) = mpsc::channel();
389 let (tx_go, rx_go) = mpsc::channel::<()>();
390 std::thread::spawn(move || {
391 let mut buf = [0u8; TS_PACKET_LEN];
392 let r0 = dev.read(&mut buf).map(|n| (n, buf));
393 if tx.send(r0).is_err() {
394 return;
395 }
396 if rx_go.recv().is_err() {
397 return;
398 }
399
400 let r1 = dev.read(&mut buf).map(|n| (n, buf));
401 if tx.send(r1).is_err() {
402 return;
403 }
404 if rx_go.recv().is_err() {
405 return;
406 }
407
408 let r2 = dev.read(&mut buf).map(|n| (n, buf));
409 let _ = tx.send(r2);
410 });
411
412 // 1: empty device (writer end open, no data written yet) — must
413 // return `Ok(0)` promptly, not block.
414 let first = rx.recv_timeout(Duration::from_secs(1));
415 let Ok(Ok((n0, _))) = first else {
416 // The reader thread may be stuck in a blocking `read`; write a
417 // byte so it can still unblock and exit, then fail.
418 let _ = writer.write_all(&[0u8; TS_PACKET_LEN]);
419 panic!(
420 "empty-device read did not return promptly (blocked past 1s) — \
421 got {first:?}; this is the #1066 hang O_NONBLOCK fixes"
422 );
423 };
424 assert_eq!(n0, 0, "empty device must read 0 bytes");
425
426 // 2: write one full packet BEFORE letting the reader proceed, so
427 // there is no race between the write and the second `read`.
428 let mut packet = [0xAAu8; TS_PACKET_LEN];
429 packet[0] = 0x47; // MPEG-2 TS sync byte — realism only, not checked by read().
430 writer.write_all(&packet).expect("write one packet");
431 tx_go.send(()).expect("reader thread must still be alive");
432
433 let (n1, buf1) = rx
434 .recv_timeout(Duration::from_secs(1))
435 .expect("reader thread must respond promptly")
436 .expect("read must not error");
437 assert_eq!(n1, TS_PACKET_LEN, "must read exactly one packet's worth");
438 assert_eq!(
439 &buf1[..n1],
440 &packet[..],
441 "must read back the exact bytes written"
442 );
443
444 // 3: drained again — back to `Ok(0)`, not blocking.
445 tx_go.send(()).expect("reader thread must still be alive");
446 let (n2, _) = rx
447 .recv_timeout(Duration::from_secs(1))
448 .expect("empty-again read did not return promptly (blocked past 1s)")
449 .expect("read must not error");
450 assert_eq!(n2, 0, "device must read 0 again once drained");
451
452 let _ = std::fs::remove_file(&path);
453 }
454
455 /// r10-W-2: a kernel frame that exactly fills [`MAX_CA_FRAME`] must be
456 /// reported as an error (possible truncation), not silently accepted as
457 /// a complete TPDU. A regular file lets us stage exactly that many bytes
458 /// without needing real CI hardware.
459 #[test]
460 fn read_rejects_a_frame_that_fills_the_scratch_buffer() {
461 let path = std::env::temp_dir().join(format!(
462 "dvb-ci-runtime-w2-fullframe-{}-{}",
463 std::process::id(),
464 std::time::SystemTime::now()
465 .duration_since(std::time::UNIX_EPOCH)
466 .expect("system clock before UNIX_EPOCH")
467 .as_nanos()
468 ));
469 std::fs::write(&path, vec![0xAAu8; MAX_CA_FRAME]).expect("stage a MAX_CA_FRAME-byte file");
470 let file = OpenOptions::new()
471 .read(true)
472 .write(true)
473 .open(&path)
474 .expect("open the staged file");
475 let mut dev = LinuxCaDevice::from_file(file, 0);
476 let mut buf = [0u8; 8192];
477 let err = dev
478 .read(&mut buf)
479 .expect_err("a full-buffer read must error");
480 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
481 let _ = std::fs::remove_file(&path);
482 }
483
484 /// Sanity: an ordinary short frame still reads through unaffected.
485 #[test]
486 fn read_still_returns_short_frames_normally() {
487 let path = std::env::temp_dir().join(format!(
488 "dvb-ci-runtime-w2-shortframe-{}-{}",
489 std::process::id(),
490 std::time::SystemTime::now()
491 .duration_since(std::time::UNIX_EPOCH)
492 .expect("system clock before UNIX_EPOCH")
493 .as_nanos()
494 ));
495 // link header (slot, connection_id) + a 3-byte "TPDU".
496 std::fs::write(&path, [0x00, 0x01, 0xDE, 0xAD, 0xBE]).expect("stage a short file");
497 let file = OpenOptions::new()
498 .read(true)
499 .write(true)
500 .open(&path)
501 .expect("open the staged file");
502 let mut dev = LinuxCaDevice::from_file(file, 0);
503 let mut buf = [0u8; 16];
504 let n = dev.read(&mut buf).expect("short read must succeed");
505 assert_eq!(n, 3);
506 assert_eq!(&buf[..3], &[0xDE, 0xAD, 0xBE]);
507 let _ = std::fs::remove_file(&path);
508 }
509
510 /// r10-W-3: `CA_GET_SLOT_INFO` returning `ENOTTY` (the driver — or, here,
511 /// a plain regular file standing in for one that doesn't support the
512 /// ioctl at all — has no CI-slot ioctl handling) must still fall back to
513 /// "present + ready", exactly as the documented EINVAL fallback did. A
514 /// regular file is a faithful stand-in: `ioctl` on a non-device fd
515 /// reliably returns `ENOTTY` on Linux, so this exercises the real
516 /// `libc::ioctl` call and its errno branch, not a mocked one.
517 #[test]
518 fn slot_info_falls_back_on_enotty() {
519 let path = std::env::temp_dir().join(format!(
520 "dvb-ci-runtime-w3-slotinfo-{}-{}",
521 std::process::id(),
522 std::time::SystemTime::now()
523 .duration_since(std::time::UNIX_EPOCH)
524 .expect("system clock before UNIX_EPOCH")
525 .as_nanos()
526 ));
527 let file = OpenOptions::new()
528 .read(true)
529 .write(true)
530 .create(true)
531 .truncate(true)
532 .open(&path)
533 .expect("create a regular file to stand in for the CA device");
534 let mut dev = LinuxCaDevice::from_file(file, 3);
535 let info = dev.slot_info().expect("ENOTTY must fall back, not error");
536 assert_eq!(info.num, 3);
537 assert!(info.module_present);
538 assert!(info.module_ready);
539 let _ = std::fs::remove_file(&path);
540 }
541 fn poll_file(file: &File, timeout: Duration) -> io::Result<bool> {
542 poll_readable(file, timeout)
543 }
544
545 /// `poll_readable` on an empty FIFO with a short timeout reports `false`
546 /// after about that long; with data it reports `true` immediately.
547 #[test]
548 fn poll_readable_reports_data_and_honours_the_timeout() {
549 let path = make_fifo();
550 let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
551 let mut writer = OpenOptions::new().write(true).open(&path).expect("writer");
552
553 let start = std::time::Instant::now();
554 assert!(
555 !dev.poll(Duration::from_millis(80)).unwrap(),
556 "empty FIFO is not readable"
557 );
558 assert!(
559 start.elapsed() >= Duration::from_millis(70),
560 "poll returned before its timeout"
561 );
562
563 writer.write_all(&[0x47; TS_PACKET_LEN]).unwrap();
564 let start = std::time::Instant::now();
565 assert!(
566 dev.poll(Duration::from_secs(5)).unwrap(),
567 "data written: readable"
568 );
569 assert!(
570 start.elapsed() < Duration::from_secs(4),
571 "must return as soon as readable"
572 );
573
574 let _ = std::fs::remove_file(&path);
575 }
576
577 /// A zero timeout never blocks.
578 #[test]
579 fn poll_readable_zero_timeout_does_not_block() {
580 let path = make_fifo();
581 let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
582 let _writer = OpenOptions::new().write(true).open(&path).expect("writer");
583 let start = std::time::Instant::now();
584 assert!(!dev.poll(Duration::ZERO).unwrap());
585 assert!(start.elapsed() < Duration::from_millis(500));
586 let _ = std::fs::remove_file(&path);
587 }
588
589 /// Sub-millisecond timeouts used to truncate to `poll(.., 0)` (returning
590 /// at once); they must now sleep their true duration and never error.
591 #[test]
592 fn poll_readable_sub_millisecond_timeout_keeps_its_duration() {
593 let path = make_fifo();
594 let mut dev = LinuxCiDataDevice::open_path(&path).expect("open_path");
595 let _writer = OpenOptions::new().write(true).open(&path).expect("writer");
596 let start = std::time::Instant::now();
597 assert!(!dev.poll(Duration::from_micros(300)).unwrap());
598 assert!(
599 start.elapsed() >= Duration::from_micros(250),
600 "a 300 us timeout was truncated: returned after {:?}",
601 start.elapsed()
602 );
603 let _ = std::fs::remove_file(&path);
604 }
605
606 /// Hang-up without data (writer closed) is not "readable" for this API:
607 /// the old code tested `revents & POLLIN` only. The device itself opens
608 /// the FIFO `O_RDWR` (it is its own writer, so no hang-up ever shows), so
609 /// this drives `poll_readable` on a separate read-only descriptor, and
610 /// proves with a raw `libc::poll` that the kernel really reports POLLHUP
611 /// there (otherwise the assertion would be vacuous).
612 #[test]
613 fn poll_readable_ignores_hangup_without_data() {
614 let path = make_fifo();
615 let reader = OpenOptions::new()
616 .read(true)
617 .custom_flags(libc::O_NONBLOCK)
618 .open(&path)
619 .expect("read-only O_NONBLOCK open");
620 let writer = OpenOptions::new().write(true).open(&path).expect("writer");
621 drop(writer); // POLLHUP on the read end, no POLLIN, no data
622
623 let mut raw = libc::pollfd {
624 fd: reader.as_raw_fd(),
625 events: libc::POLLIN,
626 revents: 0,
627 };
628 // SAFETY: `raw` is one valid pollfd for the duration of the call.
629 let r = unsafe { libc::poll(&mut raw, 1, 0) };
630 assert_eq!(r, 1, "kernel must report an event");
631 assert!(
632 raw.revents & libc::POLLHUP != 0 && raw.revents & libc::POLLIN == 0,
633 "precondition: POLLHUP without POLLIN, got revents={:#x}",
634 raw.revents
635 );
636
637 assert!(!poll_file(&reader, Duration::from_millis(50)).unwrap());
638 let _ = std::fs::remove_file(&path);
639 }
640}