Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ style. Match the surrounding file.
`SlotHeader`, `SchemaInfo`) is guarded by `MAGIC` + `VERSION`. Any layout
change requires a `VERSION` bump and updating the `static_assert`s in
`types.h`.
- **ARCHITECTURE.md ships with the change.** Any commit touching the
`types.h` encoding, a `Region.h` contract, or the publish / receive /
repair paths must include its ARCHITECTURE.md delta in the same commit.
The doc is the only map of the lock-free invariants -- a stale claim
there is a footgun armed for the next reader.

### 5.4 Comments

Expand Down
636 changes: 420 additions & 216 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,37 @@ option(BUILD_UNIT_TESTS "Build unit tests" OFF)
option(BUILD_EXAMPLES "Build examples" OFF)
option(BUILD_BENCHMARKS "Build benchmarks (requires Google Benchmark)" OFF)
option(ENABLE_TSAN "Enable ThreadSanitizer" OFF)
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
option(ENABLE_UBSAN "Enable UndefinedBehaviorSanitizer" OFF)
option(ENABLE_WERROR "Treat warnings as errors (CI)" OFF)

if(NOT MSVC)
add_compile_options(-Wall -Wextra)
if(ENABLE_WERROR)
add_compile_options(-Werror)
endif()
endif()

# TSAN cannot share a process with ASAN/UBSAN runtimes; ASAN+UBSAN together is fine.
if(ENABLE_TSAN AND (ENABLE_ASAN OR ENABLE_UBSAN))
message(FATAL_ERROR "ENABLE_TSAN cannot be combined with ENABLE_ASAN or ENABLE_UBSAN")
endif()

if(ENABLE_TSAN)
add_compile_options(-fsanitize=thread -g)
add_link_options(-fsanitize=thread)
endif()

if(ENABLE_ASAN)
add_compile_options(-fsanitize=address -g)
add_link_options(-fsanitize=address)
endif()

if(ENABLE_UBSAN)
add_compile_options(-fsanitize=undefined -g)
add_link_options(-fsanitize=undefined)
endif()

# --- Platform-specific OS sources ---
if(WIN32)
set(OS_SOURCES
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,16 @@ target_link_libraries(my_app PRIVATE kickmsg)
| `BUILD_BENCHMARKS` | `OFF` | Build benchmarks (requires Google Benchmark) |
| `ENABLE_TSAN` | `OFF` | Enable ThreadSanitizer |

## Security

Shared-memory objects are created with mode `0600` (owner-only) on
Linux and macOS, so channel payloads are not readable by other users
on a multi-user host. To share channels across users, set the
`KICKMSG_SHM_MODE` environment variable to an octal mode (e.g.
`KICKMSG_SHM_MODE=0666`) in every process that *creates* regions —
openers are unaffected. The value is parsed once per process; an
invalid value falls back to `0600` with a warning on stderr.

## Platform Support

| Platform | SharedMemory | Futex |
Expand Down
4 changes: 2 additions & 2 deletions examples/hello_diagnose.cc
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ int main()
uint64_t wp = ring->write_pos.load(std::memory_order_acquire);
ring->write_pos.store(wp + 1, std::memory_order_release);
entries[wp & hdr->sub_ring_mask].sequence.store(
kickmsg::LOCKED_SEQUENCE, std::memory_order_release);
std::cout << " Injected: LOCKED_SEQUENCE at ring 0, pos " << wp << "\n";
kickmsg::seq_lock(wp), std::memory_order_release);
std::cout << " Injected: stale lock at ring 0, pos " << wp << "\n";
}

// Fault 2: Stuck ring (simulates subscriber teardown timeout after publisher crash)
Expand Down
11 changes: 4 additions & 7 deletions include/kickmsg/Hash.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

namespace kickmsg
{
/// Optional hash helpers. Not used on any hot path intended for
/// Optional hash helpers. Not used on any hot path -- intended for
/// users filling SchemaInfo::identity without bringing their own
/// hash implementation, and for building up descriptor fingerprints
/// in user code more generally.
Expand All @@ -32,13 +32,10 @@ namespace kickmsg
/// 64-bit FNV-1a of a raw byte range. `seed` defaults to the
/// standard offset basis for one-shot hashing; pass a previous
/// hash value to chain additional bytes into it.
uint64_t fnv1a_64(void const* data, std::size_t len,
uint64_t seed = FNV1A_64_OFFSET_BASIS) noexcept;
uint64_t fnv1a_64(void const* data, std::size_t len, uint64_t seed = FNV1A_64_OFFSET_BASIS) noexcept;

/// 64-bit FNV-1a of a string. Thin wrapper around the raw-range
/// overload; preserved as a separate entry point because
/// descriptor-string hashing is by far the most common use.
uint64_t fnv1a_64(std::string_view s) noexcept;
/// 64-bit FNV-1a of a string. Thin wrapper around the raw-range version.
uint64_t fnv1a_64(std::string_view s, uint64_t seed = FNV1A_64_OFFSET_BASIS) noexcept;

/// 64-bit FNV-1a of a trivially-copyable scalar or POD. Lets
/// callers chain fields without spelling out `&v, sizeof(v)`:
Expand Down
16 changes: 8 additions & 8 deletions include/kickmsg/Naming.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@ namespace kickmsg
/// POSIX requires shm names to start with a single '/' and contain no
/// further '/' characters; Linux additionally constrains the remainder
/// to a single path component under /dev/shm. This helper produces the
/// "after the leading slash" fragment for one component callers
/// "after the leading slash" fragment for one component -- callers
/// assemble the final "/<prefix>_<topic>" path themselves.
///
/// Rules (human-readable, no hashing):
/// - strip leading '/' lets callers pass ROS-style absolute names
/// like "/robot/arm" without producing "//" or embedded slashes
/// - interior '/' becomes '.' preserves hierarchy visually
/// - strip leading '/' -- lets callers pass ROS-style absolute names
/// like "/robot/arm" without producing "//..." or embedded slashes
/// - interior '/' becomes '.' -- preserves hierarchy visually
/// ("robot/arm/joint1" -> "robot.arm.joint1")
/// - POSIX "portable filename" chars [A-Za-z0-9._-] pass through
/// - everything else becomes '_' deterministic, no collisions
/// between benign inputs, still eyeballable in `ls /dev/shm`
/// - everything else becomes '_' -- deterministic and still
/// eyeballable in `ls /dev/shm`
///
/// Throws std::invalid_argument if the result would be empty (e.g. input
/// is "", "/", "///") a blank component would produce ambiguous names
/// is "", "/", "///") -- a blank component would produce ambiguous names
/// like "/prefix_" that silently collide across callers. \p what is
/// interpolated into the exception message ("namespace", "topic", etc.).
std::string sanitize_shm_component(std::string_view s, char const* what);
Expand All @@ -44,7 +44,7 @@ namespace kickmsg
/// macOS caveat: PSHMNAMLEN (31) leaves no room for a readable name, so
/// the result is a hash and the suffix hash is truncated to fit. Two
/// distinct (namespace, suffix) pairs can therefore collide onto the
/// same shm object distinct topics would then share one region.
/// same shm object -- distinct topics would then share one region.
/// Linux names are exact and never collide. Collisions are astronomically
/// unlikely but not impossible; if it matters, keep names short enough to
/// stay readable (Linux) or verify topology out of band.
Expand Down
22 changes: 14 additions & 8 deletions include/kickmsg/Node.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ namespace kickmsg
/// Lifetime: the Node owns the underlying shared-memory mappings. All
/// Publisher, Subscriber, and BroadcastHandle objects returned by this
/// Node hold raw pointers into the mapped memory. They MUST NOT outlive
/// the Node that created them destroying the Node unmaps the memory
/// the Node that created them -- destroying the Node unmaps the memory
/// and silently invalidates all outstanding handles.
class Node
{
Expand All @@ -33,7 +33,7 @@ namespace kickmsg
// owner, tag) are sanitized into a POSIX-shm-compatible form:
// leading '/' is stripped, interior '/' becomes '.', and any char
// outside [A-Za-z0-9._-] becomes '_'. This lets callers pass
// ROS-style paths like "/robot/arm/joint1" directly the region
// ROS-style paths like "/robot/arm/joint1" directly -- the region
// ends up at "/<namespace>_robot.arm.joint1" in /dev/shm, still
// human-readable (no hashing). A component that sanitizes to the
// empty string throws std::invalid_argument.
Expand Down Expand Up @@ -61,7 +61,7 @@ namespace kickmsg
//
// The *_or_* variants relax that: either side may be the first to
// materialize the region, mirroring join_broadcast()'s behavior.
// Useful when startup order is unknown e.g. a listener service
// Useful when startup order is unknown -- e.g. a listener service
// starting before its data source.
//
// These variants take `cfg` by reference with NO default: either
Expand All @@ -77,12 +77,12 @@ namespace kickmsg
// The freshly-opened SharedRegion from the second call is
// discarded. Two Publisher handles on the same topic from the
// same Node are NOT designed for concurrent use from separate
// threads use one handle per thread instead.
// threads -- use one handle per thread instead.
//
// NOTE on cfg.schema: only the *creator* of the region bakes
// cfg.schema into the header. On the open branch (region already
// exists) cfg.schema is IGNORED and the existing region's schema
// is preserved schema is orthogonal to channel geometry and
// is preserved -- schema is orthogonal to channel geometry and
// never part of the create_or_open config-mismatch check. Use
// try_claim_topic_schema() afterwards to publish a descriptor
// regardless of which side ended up creating the region.
Expand Down Expand Up @@ -114,7 +114,7 @@ namespace kickmsg
// Thin wrappers that call SharedMemory::unlink() with the same name
// formatting used by advertise / subscribe / join_broadcast /
// create_mailbox. Safe to call whether or not this node currently
// holds a region for that name unlink is a filesystem-level
// holds a region for that name -- unlink is a filesystem-level
// operation on the SHM entry, independent of in-process handles.

void unlink_topic(char const* topic) const;
Expand All @@ -124,7 +124,7 @@ namespace kickmsg

// --- Optional payload schema descriptor ---
//
// The library never interprets schema bytes these accessors just
// The library never interprets schema bytes -- these accessors just
// forward to the SharedRegion backing the topic. Mismatch policy
// is entirely the caller's.

Expand All @@ -146,6 +146,12 @@ namespace kickmsg
std::string make_broadcast_name(char const* channel) const;
std::string make_mailbox_name(char const* owner, char const* tag) const;

// Identity hashes over the raw (pre-sanitization) coordinates plus
// a kind tag; shm-name collisions are then detected at open.
uint64_t make_topic_identity(char const* topic) const;
uint64_t make_broadcast_identity(char const* channel) const;
uint64_t make_mailbox_identity(char const* owner, char const* tag) const;

// unordered_map guarantees reference stability for elements
// (only iterators are invalidated on rehash), so pointers
// returned here remain valid across subsequent insertions.
Expand Down Expand Up @@ -180,7 +186,7 @@ namespace kickmsg
// humanoid robot can easily hold 100-300 topics (joints × (meas,
// target) + cameras + IMUs + force sensors + hands), so O(N)
// linear search over a vector/deque starts to matter. The
// duplication with SharedRegion::name() costs ~30 B per entry
// duplication with SharedRegion::name() costs ~30 B per entry --
// negligible at any scale we care about. unordered_map also
// guarantees reference stability for elements (the mmap addresses
// used by Publisher/Subscriber don't move on rehash).
Expand Down
27 changes: 22 additions & 5 deletions include/kickmsg/Publisher.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,37 @@ namespace kickmsg
Allocation allocate();

/// Commit the currently reserved slot, recording `len` as the
/// payload size. No bounds check: caller guarantees
/// `len <= max_size` from the preceding allocate().
/// payload size.
///
/// Returns the number of rings delivered to. 0 means no pending
/// allocation, oversized `len` (the pending slot is recycled), or
/// zero live subscribers -- indistinguishable by design.
std::size_t publish(std::size_t len);

/// Allocate, copy, and publish in one call.
/// Returns bytes written on success, -EMSGSIZE if too large, -EAGAIN if pool exhausted.
/// Returns bytes written on success (NOT a delivery count: a
/// successful send may have reached zero subscribers), -EMSGSIZE
/// if too large, -EAGAIN if pool exhausted.
int32_t send(void const* data, std::size_t len);

/// Number of per-ring delivery drops (CAS lock contention or pool exhaustion).
uint64_t dropped() const { return dropped_; }

private:
static uint32_t wait_and_capture_slot(Entry& e, uint64_t expected_seq,
microseconds timeout);
/// Result of waiting for the previous wrap's occupant to commit.
/// stable_lock: one lock value spanned the whole timeout window,
/// proving its (unique) holder stale -- the steal precondition.
struct CommitWait
{
uint64_t last_seq;
bool stable_lock;
};

static CommitWait wait_for_commit(Entry& e, uint64_t expected_seq,
microseconds timeout);
void self_repair(Entry& e, uint64_t pos, uint64_t capacity,
CommitWait const& wait);
void abandon_delivery(SubRingHeader* ring);
void release_slot(uint32_t idx);
void release_pending();

Expand Down
Loading
Loading