Conversation
08bc61d to
4a889ba
Compare
6545df4 to
a0a863c
Compare
c89e3c6 to
ecb9645
Compare
ecb9645 to
c7aef04
Compare
|
rebased to pick up some fixes for CI/CD failures. PTAL |
ricktaylor
left a comment
There was a problem hiding this comment.
Erik — good stuff. I went through the lot against both drafts, and also sanity-checked the API shape against the two consumers I care about: an Ethernet CLA and, unsurprisingly, your QUBICLE unreliable service. Verdict up front: architecture and codec are right, the receiver's loss-handling path needs four fixes before merge, plus a house-style pass.
The good
- Sans-io,
no_std+ alloc, three clean layers, no BPA/runtime dependency — exactly the shape that lets the CLA own the I/O. It holds up on paper against both link types. - Wire format verified against btpu-02 and the FEC draft: header, hints-before-content, transfer fields, hint type/H packing, padding rules all check out. Zero-copy
Bytesdiscipline is consistent. frame_kindis a nice touch, and on Ethernet it composes beautifully: NIC min-frame zero-padding decodes as indefinite padding and just vanishes.- 76 tests, byte-level wire assertions, reserved-range sweeps, clippy/fmt clean.
- You out-implemented the spec in one spot: the
diff != 0guard inis_new_transferdeviates from Figure 2 — correctly, because Figure 2 classifies a repeat of GREATEST as "new". That's a bug in my pseudocode, not your code; fix is queued for the next rev, at which point you're conformant as written.
Blockers — all in the receiver's loss path
- Out-of-order completion never fires. Only
process_transfer_endchecksis_complete()(receiver.rs:195); End-before-late-segment — the normal recovery sequence under §6 repetition, and routine reordering under QUIC datagrams — leaves the transfer stuck until window expiry. Check completeness on every insert oncefinal_segment_indexis known. Theout_of_order_segmentstest (receiver.rs:406) currently asserts the gap — looks like you spotted it and parked it. - Cancelled transfers resurrect. §4.2 MUST: a repeated segment after a Cancel re-creates the entry via
entry().or_insert_with. Needs a cancelled-set bounded by the window — I suspect that's what the never-constructedError::TransferCancelledwas reaching for. - Cancel of an unknown transfer isn't ignored (§8.4 MUST).
process_transfer_cancel(receiver.rs:266) emitsTransferCancelledfor never-seen numbers. - Unbounded segment accumulation.
max_bundle_sizeis only enforced post-reassembly. Enforce it on accumulated bytes as segments arrive, and theBundleLengthhint is sitting right there for early rejection — stored, never read.
Should fix
MAX_WINDOW_SIZE = 4096(transfer.rs:9); §5 says less than 2^12, so 4095.- Validate
SenderConfig:pdu_size> ~1 MiB letsenqueueexceed the 20-bit length andnext_pdupanics on the.expect()atsender.rs:267. - FEC decode never populates
source_fec_payload_id/fssi(scheme-defined boundaries, fair) but the struct shape claims otherwise anddecode(encode(m)) ≠ m. Collapse the decoded form to one opaqueBytesuntil a scheme is registered —FecSchemealready has the size accessors for a scheme-aware decode later. - Re-encoding
Message::Unknowndrops the H flag (codec.rs:461) — corrupts relayed unknown messages with hints. Related: hints parse before type dispatch, so a malformed hint chain in an unknown/padding message errors the whole PDU instead of being ignored (§8.5). value.len() as u8(hint.rs:63) andhint_type << 1silently truncate — error instead.Sender::complete()ignores its argument andcancel()releases unconditionally — bogus/duplicate calls free slots that were never allocated. Minimal fix is fine;complete()probably dies in the follow-on API work anyway.- Dead:
Error::TransferOutsideWindow,Error::TransferCancelled, thetracingdependency. randfeature pins rand_core 0.6; your own dev-deprand0.9 can't drivefrom_rng, and the workspace is on 0.10 (bpv7).
House style
Mechanical, but the repo is consistent about these:
pub mod codec / transfer / sender / receiver / messageinstead of private modules + root re-exports; onlyErrorgets flattened. Visibility at the definition, not via re-export plumbing.- With modules public, split the root
Error— codec vs transfer/sender (cf.tcpclv4'scodec::Error). Half the variants are impossible at any given call site today. - No
// ----- section -----banners. usestatements in one contiguous block, no blank lines.- Config: add
rename_all = "kebab-case"next toserde(default), and put defaults in the field docs (pdu_size's 1500 is undocumented). - README is 313 lines against a 60–100 crate norm and mostly duplicates the rustdoc — trim, keep detail in lib.rs.
- The stream-of-consciousness comments in
receiver.rs:406-415go with fix #1.
Heading
So the API nits above have context: tranche 2 on Sender/Receiver gets driven by the Ethernet and QUBICLE consumers — next_pdu(max_len) with pack-time segmentation (your datagram limit is dynamic; a fixed pdu_size with eager segmentation strands queued segments when the path MTU drops), padding policy (Fixed/min-length/none — datagrams want none), §4.1 priority interleaving, repetition as the single loss knob per §6, automatic window release on drain. None of that is this PR's problem; the codec and window layers underneath it are close to ready as-is.
Fix the four receiver items, run the style pass, and this merges. Thanks Erik.
c7aef04 to
8f819a3
Compare
|
🙏 Latest push has attempts at fixes for all of these. I have yet to review the change in full myself, but let's see what GitHub warns me about here... |
16468bb to
fa1df28
Compare
sylvain-pierrot
left a comment
There was a problem hiding this comment.
Hello Erik, AMAZING job!!
This review mainly focuses on Rust idioms and best practices, so most comments are suggestions rather than blockers; feel free to push back on any of them. 🙂
Additionally, I would suggest moving the tests that only use the public API out to tests/, from what I can see only a couple genuinely need private access.
Let me know if anything is unclear
db129a5 to
5eec9c6
Compare
|
more feedback from Rick: Code Review — PR #529
|
5eec9c6 to
8a46f8c
Compare
|
now with even more feedback taken 😁 PTAL |
|
Reviewed at head Verdict: one real conformance finding to fix (#1), one narrow API-invariant hole (#2), the rest nits. Everything from the 2026-07-25 review and Sylvain's idiom pass has been genuinely and thoroughly applied — this is close to mergeable. Prior review — all 16 findings verified fixed at this head
Sylvain's suggestions all landed too: Draft-side confirmations: the Findings1. Medium — sender window gates on transfer count, not number span (
|
8a46f8c to
ac44953
Compare
|
Thank you! I think I've processed each of these, though (4) I think didn't have anything actionable here? |
d8f95c1 to
eaa1dc8
Compare
|
Per Monday's video chat, having the naked bundle send path be outside the BTP-U CL sending code means they side step any prioritization the BTP-U sender might be applying. This is always possible, but it seems like a Good Idea (tm) to have a naked bundle send path within the BTP-U sending layer, so that prioritization can be applied (in future). That change is now made in this PR. |
ec53d0d to
9d48007
Compare
ec33cf6 to
a148904
Compare
A no_std + alloc implementation of the Bundle Transfer Protocol - Unidirectional (draft-ietf-dtn-btpu) that also frames the messages of its FEC extension (draft-ietf-dtn-btpu-fec); the target revisions are pinned once in the crate docs. It has no dependency on hardy-bpa, hardy-bpv7, or an async runtime. Message repetition, interleaving, and FEC schemes are not implemented yet; the docs say so. - codec: zero-copy PDU decoding with fault containment (a malformed message is skipped, a framing fault ends the walk keeping the prefix), byte-exact relay of unknown types, opt-in decoding of the provisional FEC types, and a BundleExtent hook for delimiting bare and encapsulated bundles. - transfer: the Section 5 window and a transfer-number allocator gated on span, not count. - sender: segmentation fixed at enqueue, PDU packing, a self-releasing window, cancellation by BundleTransferId, and fixed-size or variable link framing. Each Pdu lists the bundles it carries (a CarriedList, inline up to four entries) so a CLA can report per-bundle outcomes; next_pdu_into refills a caller-owned list. - receiver: infallible receive_pdu, where every fault and disposition is a ReceiverEvent. Memory is bounded per transfer (MaxBundleSize, optional MaxSegments) and across transfers (MaxRetainedBytes, enforced as configured, sized by for_transfers); retained_bytes exposes the charged total. - tower (feature): Service for enqueue and Stream of Pdus. Configuration is SenderConfig / ReceiverConfig, built from validated NonZero-style newtypes with shared OutOfRange and ParseError errors. docs/design.md records the design decisions, the sizing trade-offs, and suggested future work. Tests: 270 (243 with default features): 10 inline, 257 integration, 3 doctests including the README example (README.md joins the Rust CI path filter). Fuzz targets under btpu/fuzz cover the decoder and receive_pdu. The crate joins the thumbv7em-none-eabihf no_std CI job, and a new thumbv6m-none-eabi job builds it with critical-section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a148904 to
dd895a5
Compare
A pure-Rust implementation of the Bundle Transfer Protocol -
Unidirectional (draft-ietf-dtn-btpu) that also frames the messages of
the FEC extension (draft-ietf-dtn-btpu-fec); section references follow
-04 and -02 respectively, pinned once in the crate docs. BTP-U sits
between the Bundle Protocol and a frame-based convergence layer (UDP,
CCSDS, AOS, broadcast radio) and provides segmentation and transfer
windowing without requiring a return channel. The protocol's message
repetition and interleaving are not implemented by this sender yet, and
no FEC scheme is implemented; both are stated in the docs.
Pure protocol library: #![no_std] + alloc, no dependency on hardy-bpa,
hardy-bpv7, or any async runtime. Public modules with visibility at the
definition; module-scoped thiserror enums (Clone + PartialEq + Eq) with
a Result alias per module, so no call site carries error variants it
cannot produce. Faults split by trust boundary: processing untrusted
wire input surfaces events, never errors; Result is reserved for
trusted local operations (constructor validation, enqueue).
decode_pdu is a lazy MessageIter over a zero-copy receive path with
two-tier fault containment: a malformed interior is skipped via the
header length and iteration continues, while a framing fault
(truncated header, length past the buffer, encapsulated bundle of
unknown extent) stops the walk, keeping everything already parsed;
is_exhausted() distinguishes the two. decode_pdu_with takes
DecodeOptions:
fecinterprets the four provisional FEC type values(0x70..=0x73, Private Use per Section 12.1, so off by default and
otherwise relayed as Message::Unknown), and
bundle_extenttakes aBundleExtent hook through which a caller that parses bundle formats
lets the decoder delimit bare bundle frames and encapsulated bundles
(Section 7.3): with it, link padding is trimmed and iteration
continues past the bundle; without it, a bare frame is the whole PDU
and a mid-PDU bundle is terminal, and the padding pitfalls of that
fallback are documented. Unknown message types relay byte-exact,
flags nibble included; encoding refuses an Unknown carrying a defined
or reserved type. Hint chains fold to one item per type while
decoding (bounding a chain of repeats to 128 items), a malformed
Bundle Length hint is carried as an unknown hint rather than failing
its message, decode_hints slices by offset (no aliasing
precondition), encode_header returns Result instead of truncating,
and pad_pdu chains maximum-size Definite Padding messages so every
target length is reachable with truthful headers. Transfer Segment
and Transfer End share one content struct (the End is the final
segment), and the four FEC types share two (pre-agreed vs explicit),
so nothing is matched twice.
sender restart) and TransferNumberAllocator, sized by a WindowSize
newtype enforcing the Section 5 range (4..=4095). The allocator keeps
outstanding numbers in allocation order and gates on their span, not
their count (Section 5 sender MUST). The random-restart acceptance
hazard the draft leaves open is documented.
window expiry + cancellation.
Configuration is SenderConfig / ReceiverConfig, structs of validated
newtypes (PduSize, WindowSize, MaxBundleSize, SendQueueDepth) plus
LinkFraming and the FEC switch, all defaulted, so invalid sizes are
TryFrom errors at the edge, no constructor panics, and everything that
shapes queued data is fixed before anything is queued. The newtypes
(also MaxSegments and MaxRetainedBytes on the receive side) follow the
core::num::NonZero shape, and every TryFrom and FromStr returns the
shared OutOfRange and ParseError. PduSize spans one message header (MIN)
to the 20-bit content-length ceiling (MAX), so anything enqueue accepts
can always be drained.
Receiver: receive_pdu is infallible; every framing and semantic fault is
an event (MalformedMessage/MalformedPdu for decode faults,
MessageDropped/TransferRejected/BundleRejected for dispositions), so a
fault late in a PDU never discards the events of the prefix before it;
receive_pdu_into refills a caller-owned event list instead. The core and
FEC transfer messages share one pipeline (admit to the window, check the
transfer kind, check segment indices, apply, enforce limits), and
completeness is checked on every segment insert. Benign drops
(out-of-window, repeat of a delivered/cancelled/rejected transfer,
Cancel for a transfer not in progress per Section 8.4, segment-sequence
conflicts) surface as MessageDropped data. A transfer the receiver
refuses (too large, too fragmented, FEC/core mixing, a changed FEC
configuration, empty, receiver full) is reported once as
TransferRejected with its RejectReason, and its later messages are
dropped as DropReason::Rejected with the same reason. A transfer that is
over stays over for the life of the window: delivered, cancelled, and
rejected numbers are remembered, so a Section 6 repeat never re-delivers
a bundle or re-opens a phantom transfer, and a Cancel for any in-window
number is honoured even before its segments arrive (Section 5 defines
in-progress by the window range; Section 8.4 makes the later segments
discardable). Memory is bounded by what is retained: the mandatory
MaxBundleSize (default 1 GiB) polices each transfer's bundle bytes
exactly (TooLarge, also on the Bundle Length hint, FEC transfers
included) and budgets its bookkeeping separately (TooFragmented:
SEGMENT_OVERHEAD per stored segment plus retained hint bytes, against
the cap or a 4 KiB floor), so a cap-sized bundle is delivered however it
is segmented while a flood of tiny segments or hint data is still
bounded; segments shorter than half their PDU and retained hint values
are copied out so no PDU is pinned by a fragment. An optional
MaxSegments replaces the per-segment charge with a direct segment count
(MaxSegments::for_link_pdu_size derives one for links with small PDUs),
and MaxRetainedBytes bounds the state held across all in-progress
transfers, rejecting the transfer that would exceed it as ReceiverFull;
it is never less than one transfer's full allowance, which is also its
default (2 GiB with the default cap). The BundleExtent hook is a Box
borrowed by the decoder through disjoint fields, not taken and restored,
so a panicking hook cannot leave the receiver hookless. Empty segments
and an empty Transfer End are stored, since Section 4 completes a
transfer once indices 0..=N are present, so a conforming streaming
sender always completes. An empty Bundle message is rejected (Section
8.1) and a Bundle Length hint on one is dropped (Section 9.1).
BundleReceived { data, hints } carries one hint per type, most recently
received value wins; a single-segment transfer hands back its segment as
stored, without a second copy. TransferExpired events are reported
oldest first, across the 2^32 roll-over included, and an encapsulated
bundle of undeterminable extent is reported with its first byte and its
offset in the PDU. reset() discards all state for a peer restart learned
out of band.
Sender: enqueue(data, SendOptions) rejects empty bundles (Section 8.1),
returns a BundleTransferId (the transfer number for a segmented bundle,
a wrapping counter value otherwise), and attaches caller hints
(validated at the call; any caller item of the Bundle Length type is
discarded, since that hint is always sender-derived) to the Bundle
message or first segment. A segmented transfer is one queue entry: its
boundaries are fixed at enqueue against the PduSize, but its segment
messages are cut from the enqueued buffer as PDUs are packed, so a
gibibyte costs a handle rather than 720 thousand queued messages, and a
bundle needing more segments than the 32-bit index can number is
refused. The window releases itself: a transfer's slot is freed when its
End is packed by next_pdu, since a unidirectional link offers nothing to
anchor an explicit completion call to; there is no complete().
cancel(BundleTransferId) reports whether it cancelled anything: for a
transfer it frees the slot early and queues a Transfer Cancel only if
part of the transfer was emitted; a Bundle Message or bare frame still
queued is simply removed. next_pdu returns a Pdu: Bytes sized to its
content (sharing a bare frame's buffer rather than copying it), with a
Carried entry for every bundle it holds bytes of, flagged when it holds
the last, so a CLA can report per-bundle outcomes; next_pdu_into fills a
caller-owned list instead, and nothing is allocated when idle.
LinkFraming is set per link: FixedSize (default) pads every PDU to the
PduSize; Variable leaves PDUs unpadded and may emit a fitting,
hint-free, bundle-typed bundle as a bare bundle frame. Bare frames go
through the same pending queue as everything else, so they keep arrival
order and count against SendQueueDepth, which counts queue entries and
is an admission gate applied by poll_ready and is_send_queue_full, never
by enqueue. Sender's Debug summarises configuration and queue rather
than printing queued bundle bytes.
FEC messages carry a single scheme-opaque payload, so
decode(encode(m)) == m holds with no scheme registered; the earlier
unused FecScheme trait is removed until a scheme exists to shape it.
Optional features (all default-off): serde (the config structs with
kebab-case keys and defaults, newtypes as plain integers re-validated on
deserialize), rand (try_from_rng and from_rng constructors on rand_core
0.10, from a fallible RNG such as SysRng or an infallible one),
critical-section (builds on targets without atomic compare-and-swap,
such as Cortex-M0, by moving bytes onto portable-atomic), tower
(Service with From for Sender, an infallible
Service for Receiver, and a Stream<Item = Pdu> PDU drain;
poll_ready gates on window span and send-queue depth, every parked
producer is woken so several may share one Sender through a mutex, and
the shared-mutex contract is documented and tested: poll_ready and call
under one lock hold, the lock released before parking, since admission
is not reserved between them; tower::buffer::Buffer is documented as
unsuitable).
Tests: 15 inline unit tests (receiver, sender, and transfer
internals), 245 integration tests under tests/ with all features (219
with default features; one file per subject, shared fixtures in
tests/common, whole-event-list and typed-error assertions, no tokio),
and 2 doctests (1 with default features), the README example among
them (README.md is now in the Rust CI path filter for that reason):
262 in all, 235 with default features. Fuzz targets under btpu/fuzz
drive the decoder (with re-encode and byte-exact relay checks across
the FEC and extent-hook options) and Receiver::receive_pdu. The crate
is added to the thumbv7em-none-eabihf no_std CI job, and a
thumbv6m-none-eabi job builds it with critical-section. clippy -D
warnings is clean across the feature matrix (no-default, serde, rand,
tower, critical-section, default, all) and cargo doc -D warnings is
clean with and without features.
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com