Phase 07: Arc<[u8]> refactor for TrafficEntry::pcap_frame¶
Prompt¶
Before answering questions or making design decisions in this
document, read the relevant ryll source. Key files:
ryll/src/bugreport.rs:38-59 (the TrafficEntry struct where
pcap_frame: Vec<u8> lives), :81-173 (the
TrafficRingBuffer that owns entries and accounts bytes via
pcap_frame.len()), :355-388 (build_frame which currently
returns Vec<u8>, the only producer of the field), and the
two stub call sites at :1576 and :1592 inside the tests.
Cross-reference the master plan at
docs/plans/PLAN-session-001-feedback.md:104-110 (Phase 06's
Open-Question-1 resolution explicitly notes "Cheap to clone
thanks to Phase 07 (Arc<[u8]> for pcap_frame makes
per-entry clone an atomic refcount bump)") and :211-215
(the Phase 08 hard-dependency note: "Phase 08 benefits from
Phase 07's Arc<[u8]> model — segmenting into multiple
entries is cleaner when each segment is a cheap
Arc-shared chunk").
This phase is purely structural — no user-visible behaviour change, no new tests of business logic, no API surface added. It exists to enable Phase 10's notification-snapshot UX (cheap ring-buffer clones) and to make Phase 08's segmentation refactor (entry-per-segment) sit cleanly on a type that's already shared-by-default.
Situation¶
What we already established¶
TrafficEntry::pcap_frame is the only large field in the
struct. Every other field is either Copy (timestamp,
sizes, types, direction) or a &'static str pointer
(channel name, message name). At session-001's observed
display rates, each entry carries roughly 1–32 KB in
pcap_frame, depending on message size.
The only producer is TrafficBuffers::build_frame, which
returns Vec<u8> (bugreport.rs:355-388) from the
capture::build_tcp_frame helper. Both record_received
(:298-318) and record_sent (:326-349) call it and move
the result into a freshly-constructed TrafficEntry.
The only consumers are local to this file. The ring
buffer accounts bytes via entry.pcap_frame.len() and
writes the pcap export via &entry.pcap_frame deref-coerced
to &[u8]. Tests construct TrafficEntry { pcap_frame:
vec![...] } directly at two stub call sites.
Cloning a TrafficEntry today is O(payload bytes). The
derive #[derive(Clone)] on TrafficEntry delegates each
field's clone, and Vec<u8>::clone is a heap allocation
plus byte copy. The Phase 10 notification-snapshot flow ("on
notification fire, snapshot the ring buffer to a bounded
side-buffer") will need to clone N entries; with Vec
What we don't yet know¶
Nothing material. The change is structural, well-bounded, and
each call site adjustment is mechanical. The only design
question is whether to also change build_frame's return
type from Vec<u8> to Arc<[u8]> — resolved under "Approach"
below.
Mission and problem statement¶
Switch TrafficEntry::pcap_frame from Vec<u8> to
Arc<[u8]> so that:
TrafficEntry::clone()becomes a refcount-bump on the payload rather than a heap copy.- The ring-buffer byte-accounting (
entry.pcap_frame.len()) still works unchanged (Arc<[u8]> derefs to [u8]). - The pcap export path still works unchanged after a one-line deref tweak.
- The
TrafficViewEntry(bugreport.rs:62-79) is unaffected — it never carried the pcap bytes and is already cheap-to-clone.
No new behaviour. No new tests of business logic. Phase 08 and Phase 10 will exercise the cheap-clone property; Phase 07's responsibility is just to expose it.
The phase succeeds when:
git grep "pcap_frame: Vec<u8>"finds nothing.TrafficEntry::cloneis provably refcount-only on the payload (pinned viaArc::ptr_eqin a small unit test).- Every existing test passes without modification (other
than the two stub literal call sites that construct
pcap_frame: vec![0u8; 20]). make build,make lint,make testall clean.
Approach¶
Type choice: Arc<[u8]>, not Arc<Vec<u8>>¶
Arc<[u8]> is the canonical Rust idiom for a shared
immutable byte buffer:
- Single heap allocation (Arc header + payload inline).
Arc<Vec<u8>>would be two — the Arc heap node and the Vec's separate heap allocation. From<Vec<u8>> for Arc<[u8]>is in std; the conversion reuses the Vec's heap allocation by going viaBox<[u8]>. So building frombuild_frame'sVec<u8>return value is a single move, not a copy.- Deref to
[u8]is automatic, so existing.len()and slice-borrow call sites keep working.
Leave build_frame returning Vec<u8>¶
The conversion to Arc<[u8]> is a one-line .into() at the
two call sites (record_received and record_sent).
Pushing it down into build_frame would force any future
caller that wants a mutable buffer (e.g. for in-place editing
before sealing into an entry) to round-trip through
Vec::from(arc.as_ref()). Convert at the call site —
keeps build_frame as a plain builder and TrafficEntry's
construction site as the place that decides "this is
done, freeze it".
Concrete diff sketch¶
Type change (bugreport.rs:38-59):
use std::sync::Arc; // new import
#[derive(Debug, Clone)]
pub struct TrafficEntry {
// ... unchanged fields ...
/// Full pcap frame bytes (Ethernet + IP + TCP + SPICE
/// payload). `Arc<[u8]>` so Phase 10's
/// snapshot-on-notification path can clone the ring
/// buffer's entries in O(N atomic increments) rather
/// than O(total bytes).
pub pcap_frame: Arc<[u8]>,
}
Producer call sites (bugreport.rs:306, 338):
let pcap_frame: Arc<[u8]> = self
.build_frame(channel, false, raw_message, &mut guard)
.into();
// ... entry literal uses the new binding unchanged ...
Pcap-export read site (bugreport.rs:152-153): the
existing &entry.pcap_frame produces &Arc<[u8]>, which
won't deref-coerce to &[u8] directly. Two adjustments are
equivalent; use the slice-borrow form for explicitness:
let packet = PcapPacket::new(
entry.timestamp,
entry.pcap_frame.len() as u32,
&entry.pcap_frame[..], // was: &entry.pcap_frame
);
Test stub literals (bugreport.rs:1576, 1592):
The .into() form is shorter but slightly less obvious to a
reader; pick Arc::from for the explicitness.
Pin the cheap-clone property in a test¶
Add one small unit test in bugreport::tests:
#[test]
fn traffic_entry_clone_shares_pcap_frame_via_arc() {
// Phase 07 (no user-visible behaviour change) guard.
// The cheap-clone property is the whole point of the
// refactor — without this test a future change that
// turns pcap_frame back into Vec<u8> would compile and
// silently regress the Phase 10 snapshot path's
// cost model.
let entry = TrafficEntry {
timestamp: Duration::from_millis(0),
channel: "main",
direction: TrafficDirection::Received,
message_type: 1,
message_name: "test",
wire_size: 20,
payload_size: 14,
pcap_frame: Arc::from(vec![0u8; 20]),
};
let cloned = entry.clone();
assert!(
Arc::ptr_eq(&entry.pcap_frame, &cloned.pcap_frame),
"Clone must share the payload allocation; if this \
fires, pcap_frame's type was changed back to Vec<u8> \
(or another deep-copy type) and Phase 10's snapshot \
cost model is broken."
);
}
Arc::ptr_eq returns true iff both Arcs point at the same
allocation — the exact "no deep copy happened" invariant we
want to pin. Cheap, single-file, no new fixtures.
No new public-API surface¶
TrafficEntry::pcap_frame is pub today and stays pub.
The type change is technically a breaking change for any
external consumer, but ryll's bugreport module isn't
exported as a library, and grep confirms no consumer outside
the crate. The doc comment update is the only API-surface
adjustment.
Tasks¶
-
use std::sync::Arc;was already imported atbugreport.rs:11— no addition needed. - Change
TrafficEntry::pcap_framefromVec<u8>toArc<[u8]>. Doc comment updated with the snapshot-cost rationale and a pointer to the cheap-clone test. - At the two producer sites (
record_received,record_sent),build_frame'sVec<u8>return is converted withlet pcap_frame: Arc<[u8]> = ....into();Same form at both sites. - At the pcap-export read site
(
TrafficRingBuffer::write_pcap_to), changed&entry.pcap_frameto&entry.pcap_frame[..]so the slice borrow is explicit (auto-deref would also work here, but the explicit form documents the intent after the type change). - Updated the two test stub literals at
bugreport.rs:1589, 1605to constructpcap_frame: Arc::from(vec![0u8; 20]). Grep confirmed no other test sites constructTrafficEntry. - Added
traffic_entry_clone_shares_pcap_frame_via_arcinbugreport::tests. AssertsArc::ptr_eqon the original and clone — pins the cheap-clone invariant. - Updated
PLAN-session-001-feedback.mdExecution table row for Phase 07 → Done. No K-bug row to update (Phase 07 is an enabler).
Out of scope¶
- Actually exercising the cheap clone. Phase 10 is the
consumer; Phase 07's only job is to expose the property.
Wiring
snapshot_at_fireinto the notification flow is the Phase 10 deliverable and uses this phase's foundation. - Changing
build_frame's return type. Keeps the builder flexible for future mutable-buffer needs. The call-site conversion is one line. Arc<[u8]>for other byte-bearing types in the bug-report tree.BugReport'sscreenshot_region_png: Option<Vec<u8>>, the channel-state JSON payloads, the capture pcaps written to disk — none of these are cloned hot-path; converting them would be churn without benefit.- Pool / arena allocation. A future "ring buffer of ring-allocated TrafficEntries" might reuse Arc<[u8]> payload slots, but that's a separate master-plan item. Phase 07's job is just the type change.
Arc<TrafficEntry>instead ofArc<[u8]>inside an owning entry. Wrapping the whole entry in Arc would also make clones cheap, but at the cost of forcing every consumer throughArc::derefand breaking the existing by-value APIs. The payload-only change is the minimum necessary to enable Phase 10; that scope is the right one.
Open questions¶
-
Should the new test live in
bugreport::testsor in thetests/integration directory? In-module — it's a structural invariant onTrafficEntryspecifically, not a workflow test. Same shape as the other in-module unit tests. -
Should the doc comment on
pcap_framecite Phase 10 by name? Yes — it's the consumer of the cheap-clone property and the reason the type was chosen. A future maintainer reading the field declaration cold should be able to find the design rationale without grepping the plans.
Companion docs¶
None new. The ARCHITECTURE.md description of the ring
buffer (if present) describes the byte-cap eviction
behaviour, which is unchanged. AGENTS.md has no entry to
update — the refactor doesn't introduce a new pattern; it
just chooses a slightly more idiomatic type for the existing
one.