Skip to content

Commit e2edc5c

Browse files
committed
Smoke test vcan creation in GitHub CI
1 parent 7d97867 commit e2edc5c

8 files changed

Lines changed: 651 additions & 1 deletion

File tree

.github/workflows/lint.yml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,27 @@ jobs:
7878
components: llvm-tools-preview
7979
- name: Setup Rust cache
8080
uses: swatinem/rust-cache@v2
81+
- name: Setup vcan
82+
id: vcan
83+
run: |
84+
# Ubuntu 24.04 restricts unprivileged user namespaces via AppArmor.
85+
# The vcan-fixture crate needs user namespaces to create isolated vcan
86+
# interfaces without root.
87+
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
88+
sudo apt-get install -y linux-modules-extra-"$(uname -r)" && \
89+
sudo modprobe vcan && \
90+
echo "available=true" >> "$GITHUB_OUTPUT" || true
8191
- name: Setup nextest
8292
uses: taiki-e/install-action@v2
8393
with:
8494
tool: cargo-nextest,cargo-llvm-cov
8595
- name: Test
96+
run: cargo llvm-cov --no-report nextest --all-features --no-tests=warn
97+
- name: Test (vcan)
98+
if: steps.vcan.outputs.available == 'true'
99+
run: cargo llvm-cov --no-report nextest --all-features --run-ignored ignored-only
100+
- name: Coverage report
86101
run: |
87-
cargo llvm-cov --no-report nextest --all-features --no-tests=warn
88102
cargo llvm-cov report --cobertura --output-path coverage.xml
89103
head coverage.xml
90104
RATE="$(grep -o -m 1 -P '(?<=line-rate=").*?(?=")' coverage.xml | head -1)"
@@ -111,3 +125,16 @@ jobs:
111125
valColorRange: ${{ env.COVERAGE_PERCENT }}
112126
minColorRange: 40
113127
maxColorRange: 65
128+
129+
# Canary job: verifies vcan is available on the runner. Shows yellow when the
130+
# linux-modules-extra package drifts from the runner kernel version, which means the socketcan
131+
# tests in the test job are being silently skipped.
132+
vcan-available:
133+
runs-on: ubuntu-latest
134+
continue-on-error: true
135+
steps:
136+
- name: Setup vcan
137+
run: |
138+
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
139+
sudo apt-get install -y linux-modules-extra-"$(uname -r)"
140+
sudo modprobe vcan

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ resolver = "3"
33
members = [
44
"candumpr",
55
"cangenr",
6+
"vcan-fixture",
67
]
78

89
[workspace.package]
@@ -11,3 +12,9 @@ edition = "2024"
1112
license = "MIT"
1213
rust-version = "1.89"
1314
description = "Opinionated rewrites of can-utils in Rust"
15+
16+
[workspace.dependencies]
17+
ctor = "0.6"
18+
eyre = "0.6"
19+
libc = "0.2"
20+
neli = "0.7"

candumpr/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,12 @@ license.workspace = true
66
rust-version.workspace = true
77
description = "Log CAN traffic from multiple networks"
88

9+
[features]
10+
ci = []
11+
912
[dependencies]
13+
14+
[dev-dependencies]
15+
ctor.workspace = true
16+
libc.workspace = true
17+
vcan-fixture = { path = "../vcan-fixture" }

candumpr/tests/smoke.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
use std::ffi::CString;
2+
use std::{io, mem, ptr};
3+
4+
use vcan_fixture::VcanHarness;
5+
6+
#[ctor::ctor]
7+
fn setup() {
8+
vcan_fixture::enter_namespace();
9+
}
10+
11+
/// Verify that we can receive CAN frames from multiple vcan interfaces. This is the basic
12+
/// operation that candumpr needs to perform.
13+
#[test]
14+
#[cfg_attr(feature = "ci", ignore = "requires vcan")]
15+
fn recv_frames_from_multiple_interfaces() {
16+
let vcans = VcanHarness::new(2).unwrap();
17+
18+
#[repr(C)]
19+
struct CanFrame {
20+
can_id: u32,
21+
len: u8,
22+
_pad: u8,
23+
_res0: u8,
24+
_len8_dlc: u8,
25+
data: [u8; 8],
26+
}
27+
28+
// Send one frame on each interface and verify we can receive it on a separate socket.
29+
for (i, iface) in vcans.names().iter().enumerate() {
30+
let name_c = CString::new(iface.as_str()).unwrap();
31+
let ifindex = unsafe { libc::if_nametoindex(name_c.as_ptr()) };
32+
assert!(ifindex > 0, "interface {iface} not found");
33+
34+
let mut addr: libc::sockaddr_can = unsafe { mem::zeroed() };
35+
addr.can_family = libc::AF_CAN as u16;
36+
addr.can_ifindex = ifindex as i32;
37+
38+
// Separate tx and rx sockets. vcan delivers frames to all sockets bound to the
39+
// interface except the sender.
40+
let tx = unsafe { libc::socket(libc::PF_CAN, libc::SOCK_RAW, libc::CAN_RAW) };
41+
let rx = unsafe { libc::socket(libc::PF_CAN, libc::SOCK_RAW, libc::CAN_RAW) };
42+
assert!(tx >= 0, "tx socket: {}", io::Error::last_os_error());
43+
assert!(rx >= 0, "rx socket: {}", io::Error::last_os_error());
44+
45+
for fd in [tx, rx] {
46+
let ret = unsafe {
47+
libc::bind(
48+
fd,
49+
ptr::from_ref(&addr).cast::<libc::sockaddr>(),
50+
mem::size_of::<libc::sockaddr_can>() as u32,
51+
)
52+
};
53+
assert_eq!(ret, 0, "bind to {iface}: {}", io::Error::last_os_error());
54+
}
55+
56+
// Use a different source address per interface so we can verify which frame we got.
57+
let sa = i as u8;
58+
let frame = CanFrame {
59+
can_id: 0x18FECA00 | (sa as u32) | libc::CAN_EFF_FLAG,
60+
len: 3,
61+
_pad: 0,
62+
_res0: 0,
63+
_len8_dlc: 0,
64+
data: [0xAA, 0xBB, sa, 0, 0, 0, 0, 0],
65+
};
66+
67+
let written = unsafe {
68+
libc::write(
69+
tx,
70+
ptr::from_ref(&frame).cast::<libc::c_void>(),
71+
mem::size_of::<CanFrame>(),
72+
)
73+
};
74+
assert_eq!(written as usize, mem::size_of::<CanFrame>());
75+
76+
let mut recv_frame: CanFrame = unsafe { mem::zeroed() };
77+
let read = unsafe {
78+
libc::read(
79+
rx,
80+
ptr::from_mut(&mut recv_frame).cast::<libc::c_void>(),
81+
mem::size_of::<CanFrame>(),
82+
)
83+
};
84+
assert_eq!(read as usize, mem::size_of::<CanFrame>());
85+
assert_eq!(
86+
recv_frame.can_id,
87+
0x18FECA00 | (sa as u32) | libc::CAN_EFF_FLAG
88+
);
89+
assert_eq!(&recv_frame.data[..3], &[0xAA, 0xBB, sa]);
90+
91+
unsafe {
92+
libc::close(tx);
93+
libc::close(rx);
94+
}
95+
}
96+
}

docs/design/05-testing-strategy.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Testing strategy
2+
3+
## Status
4+
5+
**DRAFT**
6+
7+
## Scope
8+
9+
This document specifies how candumpr (and other tools in this workspace) are tested, given that they
10+
depend on Linux socketcan interfaces that require either real hardware or elevated permissions to
11+
create.
12+
13+
## Problem
14+
15+
candumpr interacts directly with CAN sockets. Testing requires CAN interfaces, but:
16+
17+
* Real CAN hardware is not available in CI.
18+
* Virtual CAN (vcan) interfaces require `CAP_NET_ADMIN` to create.
19+
* vcan interfaces are system-global resources, so parallel tests using shared interfaces cause
20+
interference.
21+
* Tests must run in CI (GitHub Actions) and locally without requiring root.
22+
23+
## Solution: user + network namespaces
24+
25+
Each test process enters its own isolated Linux network namespace using
26+
`unshare(CLONE_NEWUSER | CLONE_NEWNET)`. Inside the namespace, the process has `CAP_NET_ADMIN`
27+
without real root privileges, vcan interfaces are private and isolated, and everything is cleaned up
28+
when the process exits. See the [vcan-fixture](../../vcan-fixture/) crate for the implementation.
29+
30+
Constraint: `unshare(CLONE_NEWUSER)` requires a single-threaded process. The Rust test harness is
31+
multi-threaded, so namespace entry happens in a `ctor` constructor before `main()`.
32+
33+
## Test tiers
34+
35+
### Unit tests
36+
37+
No sockets, no namespaces. Config parsing, filter compilation, output formatting, filename template
38+
expansion, duration/size parsing.
39+
40+
### Integration tests
41+
42+
Run inside user + network namespaces with vcan interfaces. Socket binding, filter application,
43+
multi-interface capture, file rotation, ZSTD streaming, address claim, device resilience.
44+
45+
### End-to-end tests
46+
47+
Run the actual binary inside a network namespace. Launch candumpr, send frames with cangenr, verify
48+
output files, signal handling, config file loading.
49+
50+
## CI
51+
52+
Tests that require vcan use `#[cfg_attr(feature = "ci", ignore = "requires vcan")]`. In CI,
53+
`--all-features` enables the `ci` feature, making them `#[ignore]`. They are then run as a separate
54+
step gated on whether vcan setup succeeded:
55+
56+
A separate canary job (`vcan-available`) with `continue-on-error: true` shows yellow when the vcan
57+
module is unavailable on the runner, rather than silently skipping the tests.
58+
59+
See [lint.yml](/.github/workflows/lint.yml) for the implementation.
60+
61+
## Benchmarking
62+
63+
Benchmarks compare candumpr against candump on 4 vcan interfaces with J1939 traffic.
64+
65+
### Metrics
66+
67+
* **Frame loss** (primary): frames sent vs. frames in output
68+
* **Throughput ceiling**: send rate at which frames start dropping
69+
* **CPU usage**: total CPU time (user + system)
70+
* **Memory usage**: peak RSS
71+
72+
### Simulating the target environment
73+
74+
The target is a ~4 core ~1 GHz ARM CPU. Use `taskset` to pin benchmarks to 4 cores:
75+
76+
```sh
77+
taskset -c 0-3 cargo bench
78+
```
79+
80+
Core count is the important variable for comparing architecture options (dedicated thread pairs vs.
81+
shared threads). Clock speed matters less for relative comparison. Final validation must happen on
82+
real target hardware.
83+
84+
### Acceptance criteria
85+
86+
candumpr must not drop frames at the realistic J1939 rate (2000 frames/s per interface, 8000
87+
frames/s aggregate). At higher rates, candumpr should drop fewer frames than candump.

vcan-fixture/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "vcan-fixture"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
rust-version.workspace = true
7+
description = "Build vcan interfaces in isolated network namespaces"
8+
9+
[features]
10+
ci = []
11+
12+
[dependencies]
13+
ctor.workspace = true
14+
eyre.workspace = true
15+
libc.workspace = true
16+
neli.workspace = true

0 commit comments

Comments
 (0)