fix(diagnostics): bound formatting and release ingress before callbacks
Bound UTF-8 formatting and release diagnostic ingress before invoking callbacks. Check error-ID collisions at Rust/C++ release final link. Validation: diagnostic tests, strict Clippy, Miri and final-link cases.
This commit is contained in:
1 parent
3226ef97fe
commit
84807dde4e
12 files changed
+547
-48
No files matched your search
+12
-7
@@ -23,8 +23,15 @@ requires a nonzero explicit ID on every variant and retains no type, variant,
|
|||||||
or module string. `Display` renders the decimal ID. Renaming, moving, or
|
or module string. `Display` renders the decimal ID. Renaming, moving, or
|
||||||
reordering a Rust variant therefore does not change its receiver contract.
|
reordering a Rust variant therefore does not change its receiver contract.
|
||||||
Missing and same-enum duplicate IDs are rejected by the derive. Reusing an ID
|
Missing and same-enum duplicate IDs are rejected by the derive. Reusing an ID
|
||||||
across error enums generates the same reserved linker symbol and fails the
|
across error enums generates the same reserved symbol. Collision detection
|
||||||
final firmware build.
|
requires both defining objects to participate in compilation or final linking.
|
||||||
|
It does not inspect unextracted archive members or prove catalog-wide uniqueness.
|
||||||
|
Stable ID allocation still requires review.
|
||||||
|
|
||||||
|
The [final-link fixture](../../firmware/libs/kaptan-err-reportable-macros/tests/final_link.md)
|
||||||
|
checks Rust 1.90.0 release fat-LTO and native Linux static-library links.
|
||||||
|
Run it with `make -C firmware native-test`. Each run retains its results and
|
||||||
|
Rust compiler identity in `results.txt`.
|
||||||
|
|
||||||
`#[kaptan_err_reportable::propagate]` turns an annotated function into a
|
`#[kaptan_err_reportable::propagate]` turns an annotated function into a
|
||||||
reporting point. If a reportable `Err` leaves that function, the attribute makes
|
reporting point. If a reportable `Err` leaves that function, the attribute makes
|
||||||
@@ -76,16 +83,14 @@ different reportable error is already pending.
|
|||||||
`ERROR`, subject to bounded transport admission. The payload is
|
`ERROR`, subject to bounded transport admission. The payload is
|
||||||
`{ error_id, raised_at[96] }`. `raised_at` is a zero-padded UTF-8
|
`{ error_id, raised_at[96] }`. `raised_at` is a zero-padded UTF-8
|
||||||
`file:line` prefix for the reporting point.
|
`file:line` prefix for the reporting point.
|
||||||
The message kind itself identifies an error; no level is duplicated in the
|
Dynamic human-readable context belongs in `LOG`, while
|
||||||
payload. Dynamic human-readable context belongs in `LOG`, while
|
|
||||||
structured protocol failures remain in their owning response message.
|
structured protocol failures remain in their owning response message.
|
||||||
- Control, navigation, and safety error types are derive-ready but
|
- Control, navigation, and safety error types are derive-ready but
|
||||||
their live owners are not yet attached to a MAVLink worker.
|
their live owners are not yet attached to a MAVLink worker.
|
||||||
|
|
||||||
F722 still uses `panic-halt`; it does not claim a live panic frame or
|
F722 uses `panic-halt`, without live panic reporting or retained-record replay.
|
||||||
retained-record replay.
|
|
||||||
|
|
||||||
No diagnostic message may synchronously transmit from a panic handler, hard
|
No diagnostic message may synchronously transmit from a panic handler, hard
|
||||||
fault, ISR, DMA callback, or motor-inhibit path. A later target integration
|
fault, ISR, DMA callback, or motor-inhibit path. A later target integration
|
||||||
must inhibit outputs first, persist a validated crash record, then report it
|
must inhibit outputs first, persist a validated crash record, then report it
|
||||||
only after the next boot’s static board telemetry is available.
|
only after MAVLink is available on the next boot.
|
||||||
+16
-12
@@ -1,6 +1,6 @@
|
|||||||
# Logs
|
# Logs
|
||||||
|
|
||||||
Kaptan code uses the familiar logging macros on both host and flight targets:
|
Logging macros work on host and flight targets:
|
||||||
|
|
||||||
```rust,ignore
|
```rust,ignore
|
||||||
kaptan_log::debug!("estimator waiting for IMU");
|
kaptan_log::debug!("estimator waiting for IMU");
|
||||||
@@ -11,16 +11,22 @@ kaptan_log::trace!("prediction step complete");
|
|||||||
```
|
```
|
||||||
|
|
||||||
All five macros emit `LOG`; their level never selects a different wire
|
All five macros emit `LOG`; their level never selects a different wire
|
||||||
message. When a reportable firmware error escapes a reviewed
|
message. When a reportable firmware error escapes a
|
||||||
`#[kaptan_err_reportable::propagate]` function, it makes one bounded admission attempt
|
`#[kaptan_err_reportable::propagate]` function, it makes one bounded admission attempt
|
||||||
to the separate `ERROR` ingress.
|
to the separate `ERROR` ingress.
|
||||||
|
|
||||||
`kaptan-log` formats each event once into a fixed bounded buffer and never
|
`kaptan-log` formats each event once into fixed 160-byte UTF-8 storage. Its core
|
||||||
sends MAVLink, allocates, blocks, changes flight state, or touches DMA. The
|
storage and admission paths use no allocation or blocking, and do not send
|
||||||
composition fans the event into each enabled sink; the sole MAVLink worker
|
MAVLink, change flight state or touch DMA. Formatting invokes caller-supplied
|
||||||
drains its bounded sink queue. Ordinary log text uses one 160-byte bounded
|
`Display` implementations, which may allocate, block, ignore `fmt::Error` or do
|
||||||
record. Reportable-error records contain the explicit ID and a static reference
|
unbounded work. Overflow stops formatters that propagate the error and latches
|
||||||
to the reporting point until the MAVLink adapter copies its
|
truncation even if a formatter ignores it. Bounded storage is not a universal
|
||||||
|
worst-case execution-time guarantee. Producers must choose formatters suitable
|
||||||
|
for their execution context.
|
||||||
|
|
||||||
|
The composition fans the event into each enabled sink. The MAVLink worker drains
|
||||||
|
its bounded sink queue. Reportable-error records contain the explicit ID and a
|
||||||
|
static reference to the reporting point until the MAVLink adapter copies its
|
||||||
`file:line` into the 96-byte `raised_at` field.
|
`file:line` into the 96-byte `raised_at` field.
|
||||||
Logs and reportable errors use independent fixed atomic
|
Logs and reportable errors use independent fixed atomic
|
||||||
ingresses; interrupt and worker producers make one nonblocking admission
|
ingresses; interrupt and worker producers make one nonblocking admission
|
||||||
@@ -47,8 +53,6 @@ or the complete field when no zero is present.
|
|||||||
(2), Info (3), Warn (4), or Error (5). Off (0) is a filter-only sentinel and
|
(2), Info (3), Warn (4), or Error (5). Off (0) is a filter-only sentinel and
|
||||||
is never emitted as a `LOG` event. `LOG.flags` currently owns only the
|
is never emitted as a `LOG` event. `LOG.flags` currently owns only the
|
||||||
Truncated bit (1).
|
Truncated bit (1).
|
||||||
The `ERROR` message kind already means a reportable firmware error, so it has
|
|
||||||
no redundant level or flags field.
|
|
||||||
|
|
||||||
`error_id` is the stable nonzero catalog number explicitly assigned to the
|
`error_id` is the stable nonzero catalog number explicitly assigned to the
|
||||||
Rust error variant. Renaming or reordering a variant does not change it.
|
Rust error variant. Renaming or reordering a variant does not change it.
|
||||||
@@ -73,8 +77,8 @@ runtime log levels. F722 uses the validated filter recovered at boot, falling
|
|||||||
back to its board factory when the journal is empty or invalid.
|
back to its board factory when the journal is empty or invalid.
|
||||||
|
|
||||||
Events retain the compact typed `Level` and sink configuration retains the
|
Events retain the compact typed `Level` and sink configuration retains the
|
||||||
compact typed `LevelFilter`. Generic schema discovery exposes the filter's
|
compact typed `LevelFilter`. Schema metadata supplies the filter choices to the
|
||||||
named choices to the configurator, so the UI carries no hardcoded field list.
|
configurator.
|
||||||
Only the MAVLink adapter converts the five event levels to the typed Kaptan
|
Only the MAVLink adapter converts the five event levels to the typed Kaptan
|
||||||
wire enum.
|
wire enum.
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -1,5 +1,6 @@
|
|||||||
override SHELL := /bin/sh
|
override SHELL := /bin/sh
|
||||||
override MAKEFLAGS += --no-print-directory
|
override MAKEFLAGS += --no-print-directory
|
||||||
|
export CARGO_BUILD_JOBS ?= 2
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.DELETE_ON_ERROR:
|
.DELETE_ON_ERROR:
|
||||||
@@ -29,7 +30,7 @@ ifeq ($(PRIMARY_GOAL),run)
|
|||||||
|
|
||||||
else
|
else
|
||||||
|
|
||||||
.PHONY: help run fmt clippy test docs pre-commit check clean
|
.PHONY: help run fmt clippy test native-test docs pre-commit check clean
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@printf '%s\n' 'Usage: make firmware -- <cargo arguments...>' '' \
|
@printf '%s\n' 'Usage: make firmware -- <cargo arguments...>' '' \
|
||||||
@@ -38,6 +39,7 @@ help:
|
|||||||
' fmt Check Rust formatting' \
|
' fmt Check Rust formatting' \
|
||||||
' clippy Run host and embedded Clippy checks' \
|
' clippy Run host and embedded Clippy checks' \
|
||||||
' test Run Rust and firmware script tests' \
|
' test Run Rust and firmware script tests' \
|
||||||
|
' native-test Check cross-crate error IDs at Rust/C++ release final link' \
|
||||||
' docs Build Rust API documentation with warnings denied' \
|
' docs Build Rust API documentation with warnings denied' \
|
||||||
' pre-commit Run fmt, clippy, test, and docs' \
|
' pre-commit Run fmt, clippy, test, and docs' \
|
||||||
' check Alias for pre-commit' \
|
' check Alias for pre-commit' \
|
||||||
@@ -58,6 +60,11 @@ test:
|
|||||||
$(CARGO) test --workspace $(CARGO_FLAGS)
|
$(CARGO) test --workspace $(CARGO_FLAGS)
|
||||||
$(PYTHON) -m unittest \
|
$(PYTHON) -m unittest \
|
||||||
scripts/test-analyze-fw.py scripts/test-summarize-test-coverage.py
|
scripts/test-analyze-fw.py scripts/test-summarize-test-coverage.py
|
||||||
|
+@$(MAKE) native-test
|
||||||
|
|
||||||
|
native-test:
|
||||||
|
python3 libs/kaptan-err-reportable-macros/tests/final_link.py \
|
||||||
|
--work-dir "$(or $(CARGO_TARGET_DIR),target)/native-tests/final-link"
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
RUSTDOCFLAGS='-D warnings' $(CARGO) doc --workspace --no-deps \
|
RUSTDOCFLAGS='-D warnings' $(CARGO) doc --workspace --no-deps \
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Attribute expansion for `kaptan-err-reportable`.
|
//! Error derive and reporting-attribute expansion for `kaptan-err-reportable`.
|
||||||
|
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::{format_ident, quote};
|
use quote::{format_ident, quote};
|
||||||
@@ -12,8 +12,12 @@ use syn::{
|
|||||||
/// [`core::error::Error`] for an explicitly numbered error enum.
|
/// [`core::error::Error`] for an explicitly numbered error enum.
|
||||||
///
|
///
|
||||||
/// Every variant must declare a stable nonzero
|
/// Every variant must declare a stable nonzero
|
||||||
/// `#[kaptan_error(id = ...)]`. Duplicate IDs fail compilation within an enum
|
/// `#[kaptan_error(id = ...)]`. The derive rejects duplicates within an enum.
|
||||||
/// or crate and fail final linking when they originate in different crates.
|
/// Across enums, duplicate IDs emit the same reserved symbol. Collision detection
|
||||||
|
/// requires both defining objects to participate in compilation or final linking.
|
||||||
|
/// Unextracted archive members are not checked, so catalog allocation still needs
|
||||||
|
/// review. The fixture guide in `tests/final_link.md` describes Rust release-LTO
|
||||||
|
/// and native Linux checks. Embedded linker combinations need their own checks.
|
||||||
#[proc_macro_derive(KaptanError, attributes(kaptan_error))]
|
#[proc_macro_derive(KaptanError, attributes(kaptan_error))]
|
||||||
pub fn derive_kaptan_error(input: TokenStream) -> TokenStream {
|
pub fn derive_kaptan_error(input: TokenStream) -> TokenStream {
|
||||||
let input = parse_macro_input!(input as DeriveInput);
|
let input = parse_macro_input!(input as DeriveInput);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Cross-crate error-ID final-link checks
|
||||||
|
|
||||||
|
From the repository root, run `nix develop -c make -C firmware native-test`.
|
||||||
|
For a separate artifact directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop -c env CARGO_BUILD_JOBS=2 python3 firmware/libs/kaptan-err-reportable-macros/tests/final_link.py \
|
||||||
|
--work-dir /tmp/kaptan-final-link
|
||||||
|
```
|
||||||
|
|
||||||
|
The fixture builds two crates using the production `KaptanError` derive as
|
||||||
|
rlibs and staticlibs. Rust and C++ executables cover unique and duplicate IDs,
|
||||||
|
used and unused error values, and an unused second crate. Successful executables
|
||||||
|
must run. Expected duplicate failures must name `__kaptan_error_id_65401`.
|
||||||
|
|
||||||
|
The helper seeds its fixture lock from `firmware/Cargo.lock`, verifies registry
|
||||||
|
versions, sources and checksums, then builds with Rust 1.90.0 and `--locked
|
||||||
|
--offline`. It retains source, manifests, locks, binaries, linker output and
|
||||||
|
`results.txt` with the Rust compiler identity and all twelve outcomes. Native linker flags
|
||||||
|
are Linux-specific.
|
||||||
|
|
||||||
|
The exported `#[used]` marker detects collisions when both defining objects
|
||||||
|
participate in the link. It cannot force extraction of an unreferenced staticlib
|
||||||
|
member. The native case that calls only the first crate can therefore link despite
|
||||||
|
the duplicate ID. Rust's release-LTO fixture explicitly names the second crate
|
||||||
|
and rejects that collision. These cases do not establish catalog-wide uniqueness
|
||||||
|
across every supplied archive or every embedded linker/LTO combination. Stable
|
||||||
|
error-ID allocation still requires review.
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise the actual derive across crates at the host release-LTO final link.
|
||||||
|
|
||||||
|
Run with Python 3.11+. Build output is retained under the supplied directory.
|
||||||
|
This is a link experiment, not a trybuild or cargo-check substitute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
def run(arguments, cwd):
|
||||||
|
return subprocess.run(arguments, cwd=cwd, text=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def require_success(result):
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(result.stdout + result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def registry_packages(lock):
|
||||||
|
return {
|
||||||
|
(package["source"], package["name"], package["version"], package["checksum"])
|
||||||
|
for package in tomllib.loads(lock.decode())["package"]
|
||||||
|
if package.get("source", "").startswith("registry+")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_registry_packages(firmware_lock, fixture_lock):
|
||||||
|
packages = registry_packages(fixture_lock)
|
||||||
|
drift = packages - registry_packages(firmware_lock)
|
||||||
|
if drift:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"fixture registry dependencies differ from firmware lock: {sorted(drift)!r}"
|
||||||
|
)
|
||||||
|
return sorted(packages)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--work-dir", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
work = args.work_dir.resolve()
|
||||||
|
work.mkdir(parents=True, exist_ok=True)
|
||||||
|
runtime = Path(__file__).resolve().parents[2] / "kaptan-err-reportable"
|
||||||
|
version = run(["rustc", "+1.90.0", "-vV"], work)
|
||||||
|
require_success(version)
|
||||||
|
host = next(
|
||||||
|
line.removeprefix("host: ")
|
||||||
|
for line in version.stdout.splitlines()
|
||||||
|
if line.startswith("host: ")
|
||||||
|
)
|
||||||
|
print(version.stdout, end="")
|
||||||
|
for name in ["a", "b", "app"]:
|
||||||
|
(work / name / "src").mkdir(parents=True, exist_ok=True)
|
||||||
|
(work / "Cargo.toml").write_text("""
|
||||||
|
[workspace]
|
||||||
|
members = ["a", "b", "app"]
|
||||||
|
resolver = "2"
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
codegen-units = 1
|
||||||
|
lto = "fat"
|
||||||
|
panic = "abort"
|
||||||
|
strip = "symbols"
|
||||||
|
""")
|
||||||
|
for name in ["a", "b"]:
|
||||||
|
(work / name / "Cargo.toml").write_text(f"""
|
||||||
|
[package]
|
||||||
|
name = "error-id-{name}"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2024"
|
||||||
|
[lib]
|
||||||
|
crate-type = ["rlib", "staticlib"]
|
||||||
|
[features]
|
||||||
|
duplicate = []
|
||||||
|
unused = []
|
||||||
|
[dependencies]
|
||||||
|
kaptan-err-reportable = {{ path = {json.dumps(str(runtime))} }}
|
||||||
|
""")
|
||||||
|
unique = 65401 if name == "a" else 65402
|
||||||
|
(work / name / "src/lib.rs").write_text(f"""
|
||||||
|
#[derive(Debug, kaptan_err_reportable::KaptanError)]
|
||||||
|
pub enum Error {{
|
||||||
|
#[cfg_attr(feature = "duplicate", kaptan_error(id = 65401))]
|
||||||
|
#[cfg_attr(not(feature = "duplicate"), kaptan_error(id = {unique}))]
|
||||||
|
Fault,
|
||||||
|
}}
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn fixture_{name}() -> u16 {{
|
||||||
|
#[cfg(not(feature = "unused"))]
|
||||||
|
{{ kaptan_err_reportable::ReportableError::error_id(&Error::Fault).get() }}
|
||||||
|
#[cfg(feature = "unused")]
|
||||||
|
{{ {unique} }}
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
(work / "app/Cargo.toml").write_text("""
|
||||||
|
[package]
|
||||||
|
name = "error-id-app"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2024"
|
||||||
|
[dependencies]
|
||||||
|
error-id-a = { path = "../a" }
|
||||||
|
error-id-b = { path = "../b" }
|
||||||
|
""")
|
||||||
|
(work / "app/src/main.rs").write_text("fn main() {}\n")
|
||||||
|
firmware_lock = (runtime.parents[1] / "Cargo.lock").read_bytes()
|
||||||
|
(work / "firmware.Cargo.lock").write_bytes(firmware_lock)
|
||||||
|
fixture_lock_path = work / "Cargo.lock"
|
||||||
|
fixture_lock_path.write_bytes(firmware_lock)
|
||||||
|
# Resolve the fixture's local workspace entries while retaining locked
|
||||||
|
# registry versions. generate-lockfile would instead select newer versions.
|
||||||
|
require_success(
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"cargo",
|
||||||
|
"+1.90.0",
|
||||||
|
"metadata",
|
||||||
|
"--offline",
|
||||||
|
"--all-features",
|
||||||
|
"--format-version",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
work,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fixture_lock = fixture_lock_path.read_bytes()
|
||||||
|
packages = verify_registry_packages(firmware_lock, fixture_lock)
|
||||||
|
provenance = [
|
||||||
|
f"firmware Cargo.lock SHA-256: {hashlib.sha256(firmware_lock).hexdigest()}",
|
||||||
|
f"fixture Cargo.lock SHA-256: {hashlib.sha256(fixture_lock).hexdigest()}",
|
||||||
|
"Fixture registry dependencies verified as an exact subset of the firmware lock:",
|
||||||
|
*[
|
||||||
|
f" {name} {version} source={source} checksum={checksum}"
|
||||||
|
for source, name, version, checksum in packages
|
||||||
|
],
|
||||||
|
]
|
||||||
|
print("\n".join(provenance))
|
||||||
|
results = []
|
||||||
|
for mode in ["used", "unused", "unextracted"]:
|
||||||
|
use_b = mode != "unextracted"
|
||||||
|
rust_call = (
|
||||||
|
"assert!(std::hint::black_box(error_id_b::fixture_b()) >= 65401);"
|
||||||
|
if use_b
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
native_call = "|| fixture_b() < 65401" if use_b else ""
|
||||||
|
(work / "app/src/main.rs").write_text(f"""
|
||||||
|
extern crate error_id_b;
|
||||||
|
fn main() {{
|
||||||
|
assert_eq!(std::hint::black_box(error_id_a::fixture_a()), 65401);
|
||||||
|
{rust_call}
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
(work / "main.cpp").write_text(f"""
|
||||||
|
#include <cstdint>
|
||||||
|
extern "C" uint16_t fixture_a();
|
||||||
|
extern "C" uint16_t fixture_b();
|
||||||
|
int main() {{ return fixture_a() != 65401 {native_call}; }}
|
||||||
|
""")
|
||||||
|
for duplicate in [False, True]:
|
||||||
|
label = f"{mode}-{'duplicate' if duplicate else 'unique'}"
|
||||||
|
features = ["error-id-b/duplicate"] if duplicate else []
|
||||||
|
if mode != "used":
|
||||||
|
features += ["error-id-a/unused", "error-id-b/unused"]
|
||||||
|
target = work / "target" / label
|
||||||
|
cargo = [
|
||||||
|
"cargo",
|
||||||
|
"+1.90.0",
|
||||||
|
"build",
|
||||||
|
"--locked",
|
||||||
|
"--offline",
|
||||||
|
"--release",
|
||||||
|
"--target",
|
||||||
|
host,
|
||||||
|
"--target-dir",
|
||||||
|
str(target),
|
||||||
|
]
|
||||||
|
if features:
|
||||||
|
cargo += ["--features", ",".join(features)]
|
||||||
|
# Each archive must build independently before linking them together.
|
||||||
|
archives = run(cargo + ["-p", "error-id-a", "-p", "error-id-b"], work)
|
||||||
|
require_success(archives)
|
||||||
|
release = target / host / "release"
|
||||||
|
native = run(
|
||||||
|
[
|
||||||
|
"c++",
|
||||||
|
str(work / "main.cpp"),
|
||||||
|
str(release / "liberror_id_a.a"),
|
||||||
|
str(release / "liberror_id_b.a"),
|
||||||
|
"-ldl",
|
||||||
|
"-lpthread",
|
||||||
|
"-lm",
|
||||||
|
"-o",
|
||||||
|
str(release / "native-app"),
|
||||||
|
],
|
||||||
|
work,
|
||||||
|
)
|
||||||
|
rust = run(cargo + ["-p", "error-id-app"], work)
|
||||||
|
for kind, result, binary in [
|
||||||
|
("staticlib", native, release / "native-app"),
|
||||||
|
("rlib", rust, release / "error-id-app"),
|
||||||
|
]:
|
||||||
|
(work / f"{label}-{kind}.log").write_text(result.stdout + result.stderr)
|
||||||
|
if not duplicate:
|
||||||
|
require_success(result)
|
||||||
|
elif (use_b or kind == "rlib") and result.returncode == 0:
|
||||||
|
raise RuntimeError(f"{label} {kind}: duplicate unexpectedly linked")
|
||||||
|
if result.returncode == 0:
|
||||||
|
require_success(run([str(binary)], work))
|
||||||
|
outcome = "linked and ran"
|
||||||
|
else:
|
||||||
|
if "__kaptan_error_id_65401" not in result.stderr:
|
||||||
|
raise RuntimeError(result.stderr)
|
||||||
|
outcome = "duplicate ID rejected"
|
||||||
|
results.append(f"{label} {kind}: {outcome}")
|
||||||
|
print("\n".join(results))
|
||||||
|
(work / "results.txt").write_text(
|
||||||
|
version.stdout + "\n".join(provenance + results) + "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -78,6 +78,54 @@ mod tests {
|
|||||||
use super::ErrorIngress;
|
use super::ErrorIngress;
|
||||||
use crate::ErrorId;
|
use crate::ErrorId;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_publication_and_take_preserve_complete_unique_records() {
|
||||||
|
let ingress = ErrorIngress::new();
|
||||||
|
let start = std::sync::Barrier::new(3);
|
||||||
|
let mut records = std::thread::scope(|scope| {
|
||||||
|
let ingress = &ingress;
|
||||||
|
let start = &start;
|
||||||
|
let producers: [_; 2] = core::array::from_fn(|producer| {
|
||||||
|
scope.spawn(move || {
|
||||||
|
start.wait();
|
||||||
|
for sequence in 0..8 {
|
||||||
|
ingress.publish(
|
||||||
|
ErrorId::new(1000 + (producer * 8 + sequence) as u16),
|
||||||
|
["left", "right"][producer],
|
||||||
|
);
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let consumer = scope.spawn(move || {
|
||||||
|
start.wait();
|
||||||
|
let mut records = std::vec::Vec::new();
|
||||||
|
for _ in 0..64 {
|
||||||
|
records.extend(ingress.take());
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
records
|
||||||
|
});
|
||||||
|
for producer in producers {
|
||||||
|
producer.join().unwrap();
|
||||||
|
}
|
||||||
|
consumer.join().unwrap()
|
||||||
|
});
|
||||||
|
records.extend(ingress.take());
|
||||||
|
assert!(ingress.take().is_none());
|
||||||
|
assert!(!records.is_empty());
|
||||||
|
|
||||||
|
// Contention may discard publications. Every retained record must be
|
||||||
|
// one complete publication, and it may be consumed only once.
|
||||||
|
let mut seen = [false; 16];
|
||||||
|
for record in records {
|
||||||
|
let index = usize::from(record.id().checked_sub(1000).unwrap());
|
||||||
|
assert!(index < seen.len());
|
||||||
|
assert_eq!(record.raised_at(), ["left", "right"][index / 8]);
|
||||||
|
assert!(!core::mem::replace(&mut seen[index], true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retains_the_first_id_and_location_until_consumed() {
|
fn retains_the_first_id_and_location_until_consumed() {
|
||||||
let ingress = ErrorIngress::new();
|
let ingress = ErrorIngress::new();
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
//! Stable reportable firmware errors with bounded diagnostic ingress.
|
//! Stable reportable firmware errors with bounded diagnostic ingress.
|
||||||
//!
|
//!
|
||||||
//! This crate owns error identity and ingress. It contains no logging levels,
|
|
||||||
//! filtering, MAVLink, board, filesystem, or clock dependency.
|
|
||||||
//!
|
|
||||||
//! `#[propagate]` turns an annotated function into a reporting point. When a
|
//! `#[propagate]` turns an annotated function into a reporting point. When a
|
||||||
//! reportable error leaves that function, the macro makes one reporting
|
//! reportable error leaves that function, the macro makes one reporting
|
||||||
//! attempt. Nested annotated callers would therefore attempt to report the
|
//! attempt. Nested annotated callers would therefore attempt to report the
|
||||||
|
|||||||
@@ -71,18 +71,18 @@ impl<const N: usize> LogIngress<N> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drain_one<R>(&self, consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
fn drain_one<R>(&self, consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
||||||
self.with_state(|entries, head, len| {
|
let record = self
|
||||||
if *len == 0 {
|
.with_state(|entries, head, len| {
|
||||||
return None;
|
if *len == 0 {
|
||||||
}
|
return None;
|
||||||
let record = entries[*head].as_ref()?;
|
}
|
||||||
let result = consume(record);
|
let record = entries[*head].take();
|
||||||
entries[*head] = None;
|
*head = (*head + 1) % N;
|
||||||
*head = (*head + 1) % N;
|
*len -= 1;
|
||||||
*len -= 1;
|
record
|
||||||
Some(result)
|
})
|
||||||
})
|
.flatten()?;
|
||||||
.flatten()
|
Some(consume(&record))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_state<R>(
|
fn with_state<R>(
|
||||||
@@ -116,8 +116,9 @@ pub fn publish(level: Level, arguments: fmt::Arguments<'_>) {
|
|||||||
|
|
||||||
/// Visits and removes the oldest pending log event.
|
/// Visits and removes the oldest pending log event.
|
||||||
///
|
///
|
||||||
/// The callback runs during the ingress's one nonblocking ownership attempt;
|
/// The record is removed and exclusive ingress access is released before the
|
||||||
/// it should perform only bounded composition-level sink fanout.
|
/// callback runs. Reentrant logging can use the freed slot. If the callback
|
||||||
|
/// unwinds, this record is consumed and other pending records remain available.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn drain_one<R>(consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
pub fn drain_one<R>(consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
||||||
GLOBAL_INGRESS.drain_one(consume)
|
GLOBAL_INGRESS.drain_one(consume)
|
||||||
@@ -138,6 +139,70 @@ mod tests {
|
|||||||
core::str::from_utf8(&text.bytes()[..usize::from(text.byte_length())]).unwrap()
|
core::str::from_utf8(&text.bytes()[..usize::from(text.byte_length())]).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_publication_and_drain_preserve_complete_unique_records() {
|
||||||
|
let ingress = LogIngress::<2>::new();
|
||||||
|
// A critical record cannot be evicted. This guarantees one observation
|
||||||
|
// even if contention rejects every concurrent publication.
|
||||||
|
ingress.publish(Level::Error, core::format_args!("initial"));
|
||||||
|
let start = std::sync::Barrier::new(3);
|
||||||
|
let mut records = std::thread::scope(|scope| {
|
||||||
|
let ingress = &ingress;
|
||||||
|
let start = &start;
|
||||||
|
let producers: [_; 2] = core::array::from_fn(|producer| {
|
||||||
|
scope.spawn(move || {
|
||||||
|
start.wait();
|
||||||
|
for sequence in 0..8 {
|
||||||
|
ingress.publish(
|
||||||
|
[Level::Info, Level::Error][producer],
|
||||||
|
core::format_args!("{producer}:{sequence}"),
|
||||||
|
);
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let consumer = scope.spawn(move || {
|
||||||
|
start.wait();
|
||||||
|
let mut records = std::vec::Vec::new();
|
||||||
|
for _ in 0..64 {
|
||||||
|
records.extend(ingress.drain_one(|record| *record));
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
records
|
||||||
|
});
|
||||||
|
for producer in producers {
|
||||||
|
producer.join().unwrap();
|
||||||
|
}
|
||||||
|
consumer.join().unwrap()
|
||||||
|
});
|
||||||
|
for _ in 0..2 {
|
||||||
|
records.extend(ingress.drain_one(|record| *record));
|
||||||
|
}
|
||||||
|
assert!(ingress.drain_one(|_| ()).is_none());
|
||||||
|
|
||||||
|
// Contention and critical replacement may discard publications. A
|
||||||
|
// delivered record must retain its own level and text without repeats.
|
||||||
|
let mut seen = [false; 16];
|
||||||
|
let mut initial_seen = false;
|
||||||
|
for record in records {
|
||||||
|
if message(&record) == "initial" {
|
||||||
|
assert_eq!(record.level(), Level::Error);
|
||||||
|
assert!(!core::mem::replace(&mut initial_seen, true));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (producer, sequence) = message(&record).split_once(':').unwrap();
|
||||||
|
let producer: usize = producer.parse().unwrap();
|
||||||
|
let sequence: usize = sequence.parse().unwrap();
|
||||||
|
assert!(producer < 2 && sequence < 8);
|
||||||
|
assert_eq!(record.level(), [Level::Info, Level::Error][producer]);
|
||||||
|
assert!(!core::mem::replace(
|
||||||
|
&mut seen[producer * 8 + sequence],
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert!(initial_seen);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_macro_produces_the_same_log_record_kind() {
|
fn every_macro_produces_the_same_log_record_kind() {
|
||||||
let _guard = GLOBAL_INGRESS_TEST
|
let _guard = GLOBAL_INGRESS_TEST
|
||||||
@@ -213,4 +278,48 @@ mod tests {
|
|||||||
Some(())
|
Some(())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_can_publish_and_drain_reentrantly() {
|
||||||
|
let ingress = LogIngress::<1>::new();
|
||||||
|
ingress.publish(Level::Info, core::format_args!("outer"));
|
||||||
|
assert_eq!(
|
||||||
|
ingress.drain_one(|record| {
|
||||||
|
assert!(!ingress.busy.load(Ordering::Relaxed));
|
||||||
|
ingress.publish(Level::Info, core::format_args!("inner"));
|
||||||
|
assert_eq!(
|
||||||
|
ingress.drain_one(|inner| assert_eq!(message(inner), "inner")),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert_eq!(message(record), "outer");
|
||||||
|
}),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert!(ingress.drain_one(|_| ()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_unwind_leaves_ingress_free_and_other_records_pending() {
|
||||||
|
let ingress = LogIngress::<2>::new();
|
||||||
|
ingress.publish(Level::Info, core::format_args!("consumed"));
|
||||||
|
ingress.publish(Level::Error, core::format_args!("pending"));
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
ingress.drain_one(|record| {
|
||||||
|
assert_eq!(message(record), "consumed");
|
||||||
|
panic!("consumer failed");
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(!ingress.busy.load(Ordering::Relaxed));
|
||||||
|
ingress.publish(Level::Info, core::format_args!("new"));
|
||||||
|
assert_eq!(
|
||||||
|
ingress.drain_one(|record| assert_eq!(message(record), "pending")),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ingress.drain_one(|record| assert_eq!(message(record), "new")),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert!(ingress.drain_one(|_| ()).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
//! Bounded, transport-neutral runtime logging for Kaptan.
|
//! Bounded, transport-neutral runtime logging for Kaptan.
|
||||||
//!
|
//!
|
||||||
//! This crate deliberately contains no MAVLink, board, filesystem, or clock
|
//! Each sink filters and queues events for a transport adapter. Logging must
|
||||||
//! dependency. Its sole configuration coupling is compile-time enum metadata
|
//! not affect flight-control decisions or actuator authority.
|
||||||
//! for [`LevelFilter`]. Flight code reports bounded events to
|
//!
|
||||||
//! independently filtered sink queues; a platform-owned adapter may later
|
//! Text overflow stops formatting that propagates `core::fmt::Error`. Custom
|
||||||
//! encode one queue for a link.
|
//! `Display` implementations may ignore that error or perform arbitrary work,
|
||||||
//! Reporting is observational and must never influence flight control or
|
//! so fixed storage and queue bounds do not establish a universal time bound.
|
||||||
//! actuator authority.
|
|
||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ impl LogText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Formats one message without allocation.
|
/// Formats one message without allocation.
|
||||||
|
///
|
||||||
|
/// Overflow stops formatters that propagate `fmt::Error`. A custom `Display`
|
||||||
|
/// implementation can ignore that error or do unbounded work of its own.
|
||||||
pub(crate) fn from_arguments(arguments: fmt::Arguments<'_>) -> Self {
|
pub(crate) fn from_arguments(arguments: fmt::Arguments<'_>) -> Self {
|
||||||
let mut text = Self::new();
|
let mut text = Self::new();
|
||||||
let _ = fmt::write(&mut text, arguments);
|
let _ = fmt::write(&mut text, arguments);
|
||||||
@@ -58,6 +61,9 @@ impl LogText {
|
|||||||
|
|
||||||
impl fmt::Write for LogText {
|
impl fmt::Write for LogText {
|
||||||
fn write_str(&mut self, message: &str) -> fmt::Result {
|
fn write_str(&mut self, message: &str) -> fmt::Result {
|
||||||
|
if self.is_truncated() {
|
||||||
|
return Err(fmt::Error);
|
||||||
|
}
|
||||||
let remaining = MAXIMUM_LOG_TEXT_BYTES - usize::from(self.length);
|
let remaining = MAXIMUM_LOG_TEXT_BYTES - usize::from(self.length);
|
||||||
let prefix = if message.len() <= remaining {
|
let prefix = if message.len() <= remaining {
|
||||||
message.len()
|
message.len()
|
||||||
@@ -73,13 +79,18 @@ impl fmt::Write for LogText {
|
|||||||
let used = usize::from(self.length);
|
let used = usize::from(self.length);
|
||||||
self.bytes[used..used + prefix].copy_from_slice(&message.as_bytes()[..prefix]);
|
self.bytes[used..used + prefix].copy_from_slice(&message.as_bytes()[..prefix]);
|
||||||
self.length = (used + prefix) as u8;
|
self.length = (used + prefix) as u8;
|
||||||
Ok(())
|
if self.is_truncated() {
|
||||||
|
Err(fmt::Error)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{LogText, MAXIMUM_LOG_TEXT_BYTES};
|
use super::{LogText, MAXIMUM_LOG_TEXT_BYTES};
|
||||||
|
use core::fmt::{self, Write};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn truncates_only_at_utf8_boundaries() {
|
fn truncates_only_at_utf8_boundaries() {
|
||||||
@@ -95,4 +106,61 @@ mod tests {
|
|||||||
MAXIMUM_LOG_TEXT_BYTES - 1
|
MAXIMUM_LOG_TEXT_BYTES - 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_multibyte_fit_is_not_truncated() {
|
||||||
|
let input = "x".repeat(MAXIMUM_LOG_TEXT_BYTES - 2) + "é";
|
||||||
|
let mut text = LogText::new();
|
||||||
|
assert_eq!(text.write_str(&input), Ok(()));
|
||||||
|
assert_eq!(text.write_str(""), Ok(()));
|
||||||
|
assert!(!text.is_truncated());
|
||||||
|
assert_eq!(text.bytes().as_slice(), input.as_bytes());
|
||||||
|
assert_eq!(text.write_str("a"), Err(fmt::Error));
|
||||||
|
assert!(text.is_truncated());
|
||||||
|
assert_eq!(text.bytes().as_slice(), input.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_latches_even_if_a_formatter_ignores_the_error() {
|
||||||
|
struct Fragments;
|
||||||
|
impl fmt::Display for Fragments {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&"x".repeat(MAXIMUM_LOG_TEXT_BYTES - 3))?;
|
||||||
|
formatter.write_str("é")?;
|
||||||
|
let _ = formatter.write_str("🙂");
|
||||||
|
let _ = formatter.write_str("a");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = LogText::from_arguments(format_args!("{Fragments}"));
|
||||||
|
let expected = "x".repeat(MAXIMUM_LOG_TEXT_BYTES - 3) + "é";
|
||||||
|
assert!(text.is_truncated());
|
||||||
|
assert_eq!(
|
||||||
|
&text.bytes()[..usize::from(text.byte_length())],
|
||||||
|
expected.as_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(text.bytes()[MAXIMUM_LOG_TEXT_BYTES - 1], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_standard_padding_stops_at_the_first_overflow() {
|
||||||
|
struct CountWrites {
|
||||||
|
text: LogText,
|
||||||
|
writes: usize,
|
||||||
|
}
|
||||||
|
impl fmt::Write for CountWrites {
|
||||||
|
fn write_str(&mut self, message: &str) -> fmt::Result {
|
||||||
|
self.writes += 1;
|
||||||
|
self.text.write_str(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut output = CountWrites {
|
||||||
|
text: LogText::new(),
|
||||||
|
writes: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(write!(output, "{:>65535}", "end"), Err(fmt::Error));
|
||||||
|
assert!(output.writes <= MAXIMUM_LOG_TEXT_BYTES + 1);
|
||||||
|
assert!(output.text.is_truncated());
|
||||||
|
assert_eq!(output.text.bytes(), &[b' '; MAXIMUM_LOG_TEXT_BYTES]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user