Skip to content
Merged
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ jobs:
# The vcan-fixture crate needs user namespaces to create isolated vcan
# interfaces without root.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
sudo apt-get update
sudo apt-get install -y linux-modules-extra-"$(uname -r)" && \
sudo modprobe vcan && \
echo "available=true" >> "$GITHUB_OUTPUT" || true
Expand Down Expand Up @@ -150,5 +151,6 @@ jobs:
- name: Setup vcan
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
sudo apt-get update
sudo apt-get install -y linux-modules-extra-"$(uname -r)"
sudo modprobe vcan
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ focus on the user impact** rather than the actual changes made.

## Fixed

# candemonium - 0.1.1 - (2026-08-10)

Productionizability polish on the 0.1.0 MVP release.

* Add a `[interfaces.<iface>].name` config field for human-meaningful interface names. Shows up in
logs and the filename if present.
* Rx/Tx direction is captured and added to `candump-file` format
* `[receiver].batch_size` can now be set
* Various DEBUG logging fixes. Error frames are now logged at TRACE level, with future logging
enhancements planned as a part of <https://github.com/Notgnoshi/candemonium/issues/61> and
<https://github.com/Notgnoshi/candemonium/issues/19>.

The addition of the new config fields _should_ make this a SemVer 0.2.0 release, but candemonium has
no consumers yet ;)

# candemonium - 0.1.0 - (2026-08-07)

First release of candemonium with an MVP release of the `candumpr` logging tool.
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license = "MIT"
rust-version = "1.89"
Expand Down
123 changes: 111 additions & 12 deletions candumpr/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use eyre::WrapErr;

use crate::format::TimestampMode;
use crate::quantity::Quantity;
use crate::recv::receiver::BATCH_CAPACITY;
use crate::sink::{DEFAULT_FLUSH_INTERVAL, DEFAULT_SYNC_INTERVAL, Output};

/// The first interface name that appears more than once.
Expand Down Expand Up @@ -90,6 +91,19 @@ pub struct Cli {
#[arg(long, conflicts_with = "daemon")]
pub no_request_address_claims: bool,

/// Number of received frames to wait for before each receiver wakeup.
///
/// Larger batches reduce CPU overhead at high frame rates at the cost of recv latency, up to
/// 100ms.
#[arg(
long,
value_name = "N",
default_value = "4",
value_parser = clap::value_parser!(u16).range(1..=BATCH_CAPACITY as i64),
conflicts_with = "daemon"
)]
pub batch_size: u16,

/// Log level for tracing output on stderr.
#[arg(long, default_value = "INFO")]
pub log_level: tracing::Level,
Expand All @@ -99,6 +113,8 @@ pub struct Cli {
#[derive(Debug, PartialEq, Eq)]
pub struct InterfaceConfig {
pub name: String,
/// Optional user-given name, as written in the config file, to include in log messages
pub display_name: Option<String>,
pub request_address_claims: bool,
}

Expand All @@ -115,6 +131,8 @@ pub struct Config {
pub streams: Vec<StreamConfig>,
/// Whether recoverable [Sink](crate::sink::Sink) activation failures are retried or are fatal.
pub retry_activation_failures: bool,
/// Number of received frames to wait for before each receiver wakeup.
pub batch_size: usize,
}

/// Configuration for one output stream
Expand Down Expand Up @@ -221,6 +239,8 @@ struct RawStreamConfig {
format: Format,
compress: bool,
timestamp: TimestampMode,
// Only allowed in [interface.<name>] sections
name: Option<String>,
// This is the one TOML setting that's required; the rest have default values taken from
// [RawStreamConfig::default].
directory: Option<PathBuf>,
Expand All @@ -241,6 +261,7 @@ impl Default for RawStreamConfig {
format: Format::CandumpFile,
compress: true,
timestamp: TimestampMode::Absolute,
name: None,
directory: None,
flush_every: Interval::Every(DEFAULT_FLUSH_INTERVAL),
sync_every: Interval::Every(DEFAULT_SYNC_INTERVAL),
Expand All @@ -251,6 +272,18 @@ impl Default for RawStreamConfig {
}
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(default)]
struct RawReceiverConfig {
batch_size: usize,
}

impl Default for RawReceiverConfig {
fn default() -> Self {
RawReceiverConfig { batch_size: 32 }
}
}

/// The TOML config file settings
///
/// This struct exists to facilitate the TOML parsing. But the [Config] struct is what we end up
Expand All @@ -264,6 +297,25 @@ struct Raw {
defaults: RawStreamConfig,
#[serde(default)]
interface: HashMap<String, RawStreamConfig>,
#[serde(default)]
receiver: RawReceiverConfig,
}

/// Lowercase `name` and collapse every run of characters outside [a-z0-9] to a single hyphen.
fn sluggify(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
for ch in name.chars() {
let ch = ch.to_ascii_lowercase();
if ch.is_ascii_alphanumeric() {
slug.push(ch);
} else if !slug.is_empty() && !slug.ends_with('-') {
slug.push('-');
}
}
if slug.ends_with('-') {
slug.pop();
}
slug
}

/// Layer `over` onto `base`: every key in `over` replaces the same key in `base`.
Expand Down Expand Up @@ -325,6 +377,7 @@ impl Config {
.map(|interface| Output::Template {
dir: ".".into(),
interface: interface.clone(),
name: None,
ext: cli.format.ext(cli.compress).to_string(),
})
.collect()
Expand Down Expand Up @@ -355,6 +408,7 @@ impl Config {
.iter()
.map(|name| InterfaceConfig {
name: name.clone(),
display_name: None,
request_address_claims: !cli.no_request_address_claims,
})
.collect();
Expand All @@ -363,6 +417,7 @@ impl Config {
interfaces,
streams,
retry_activation_failures: false,
batch_size: cli.batch_size as usize,
})
}

Expand All @@ -385,11 +440,12 @@ impl Config {

let mut names: Vec<String> = raw.interface.keys().cloned().collect();
names.sort_unstable();
drop(raw); // just parsed for type-checking and valid TOML syntax. Overlaying is all done on the toml::Table level below.
let batch_size = raw.receiver.batch_size;
eyre::ensure!(
!names.is_empty(),
"no [interface.<name>] sections; at least one is required"
(1..=BATCH_CAPACITY).contains(&batch_size),
"receiver batch_size must be between 1 and {BATCH_CAPACITY}, got {batch_size}"
);
drop(raw); // just parsed for type-checking and valid TOML syntax. Overlaying is all done on the toml::Table level below.

// Second pass: Overlay each interface's table over the [defaults] table and deserialize the
// result, so absent keys fall through to Settings::default().
Expand All @@ -399,6 +455,10 @@ impl Config {
.and_then(toml::Value::as_table)
.cloned()
.unwrap_or_default();
eyre::ensure!(
!defaults.contains_key("name"),
"`name` is not allowed in [defaults]; set it in each [interface.<iface>] section"
);
let sections = table
.get("interface")
.and_then(toml::Value::as_table)
Expand All @@ -423,6 +483,17 @@ impl Config {
"interface {interface}: missing `directory` setting in [interface.{interface}] or [defaults]"
);
};
let name = match &settings.name {
Some(raw) => {
let slug = sluggify(raw);
eyre::ensure!(
!slug.is_empty(),
"interface {interface}: name {raw:?} has no usable characters after sluggification"
);
Some(slug)
}
None => None,
};
// A retention limit at or below the rotation limit can never be met. Only same-kind
// limits are comparable and therefore validatable; mixed kinds are best-effort.
match (settings.retain, settings.rotate_every) {
Expand All @@ -440,12 +511,14 @@ impl Config {
}
interfaces.push(InterfaceConfig {
name: interface.clone(),
display_name: settings.name.clone(),
request_address_claims: settings.request_address_claims,
});
streams.push(StreamConfig {
output: Output::Template {
dir: directory.join(interface),
interface: interface.clone(),
name,
ext: settings.format.ext(settings.compress).to_string(),
},
format: settings.format,
Expand All @@ -462,6 +535,7 @@ impl Config {
interfaces,
streams,
retry_activation_failures: true,
batch_size,
})
}
}
Expand Down Expand Up @@ -490,6 +564,7 @@ mod tests {
flush_every = "250ms"
rotate_every = "off"
retain = "10 files"
name = "Engine Bus (J1939)"
"#;
let config = Config::from_toml(src).unwrap();

Expand All @@ -498,22 +573,26 @@ mod tests {
[
InterfaceConfig {
name: "can0".to_string(),
display_name: Some("Engine Bus (J1939)".to_string()),
request_address_claims: true,
},
InterfaceConfig {
name: "can1".to_string(),
display_name: None,
request_address_claims: true,
},
]
);
assert!(config.retry_activation_failures);
assert_eq!(config.batch_size, 32);
assert_eq!(
config.streams,
[
StreamConfig {
output: Output::Template {
dir: "/var/log/can/can0".into(),
interface: "can0".to_string(),
name: Some("engine-bus-j1939".to_string()),
ext: "txt.zst".to_string(),
},
format: Format::CandumpConsole,
Expand All @@ -529,6 +608,7 @@ mod tests {
output: Output::Template {
dir: "/var/log/can/can1".into(),
interface: "can1".to_string(),
name: None,
ext: "log".to_string(),
},
format: Format::CandumpFile,
Expand Down Expand Up @@ -646,15 +726,6 @@ mod tests {
"got: {err}"
);

let err = format!(
"{:#}",
Config::from_toml("[defaults]\ndirectory = \"/x\"\n").unwrap_err()
);
assert!(err.contains("at least one is required"), "got: {err}");

let err = format!("{:#}", Config::from_toml("[interface]\n").unwrap_err());
assert!(err.contains("at least one is required"), "got: {err}");

let err = format!(
"{:#}",
Config::from_toml(
Expand All @@ -672,5 +743,33 @@ mod tests {
"[defaults]\ndirectory = \"/x\"\nrotate_every = \"200KB\"\nretain = \"1 day\"\n[interface.can0]\n"
)
.unwrap();

let err = format!(
"{:#}",
Config::from_toml(
"[defaults]\ndirectory = \"/x\"\nname = \"shared\"\n[interface.can0]\n"
)
.unwrap_err()
);
assert!(err.contains("not allowed in [defaults]"), "got: {err}");

let err = format!(
"{:#}",
Config::from_toml("[defaults]\ndirectory = \"/x\"\n[interface.can0]\nname = \"!!!\"\n")
.unwrap_err()
);
assert!(err.contains("no usable characters"), "got: {err}");

let err = format!(
"{:#}",
Config::from_toml(
"[defaults]\ndirectory = \"/x\"\n[interface.can0]\n[receiver]\nbatch_size = 0\n"
)
.unwrap_err()
);
assert!(
err.contains("batch_size must be between 1 and 256"),
"got: {err}"
);
}
}
Loading