1use crate::telemetry::{TelemetryReader, telemetry_channel};
4use crate::twin::{LiveReplay, TwinFrame, TwinReceiverStatus, TwinStatus, TwinWorker};
5use crate::{
6 CaptureArchive, CuStreamRx, SessionEvent, SessionRouter, SessionRouterLimits, StreamIdentity,
7};
8use cu29_traits::{CuError, CuResult};
9use cu29_unifiedlog::{UnifiedLogger, UnifiedLoggerBuilder};
10use std::marker::PhantomData;
11use std::num::NonZeroUsize;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::thread::{self, JoinHandle};
16use std::time::{Duration, Instant};
17
18const REPLAY_CAPACITY: NonZeroUsize = NonZeroUsize::new(32).unwrap();
19const FRAME_CAPACITY: NonZeroUsize = NonZeroUsize::new(64).unwrap();
20const SLAB_BYTES: usize = 16 * 1024 * 1024;
21const SECTION_BYTES: usize = 128 * 1024;
23const CONTINUITY_OVERHEAD: usize = 32;
24const POLL_INTERVAL: Duration = Duration::from_millis(1);
25
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
28pub enum CuTwinRecordingState {
29 #[default]
30 Waiting,
31 Recording,
32 Closed,
33 Failed,
34}
35
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub struct CuTwinStatus {
39 pub latest: Option<u64>,
40 pub recovery_point: Option<u64>,
41 pub gaps: usize,
42 pub packets: usize,
43 pub archived: u64,
44 pub structured_logs: u64,
45 pub identity: Option<StreamIdentity>,
46 pub last_packet: Option<Instant>,
47 pub state: CuTwinRecordingState,
48 pub twin: TwinStatus,
49 pub feedback_reports_sent: u64,
50 pub feedback_reports_dropped: u64,
51 pub feedback_failed: bool,
52}
53
54impl TwinReceiverStatus for CuTwinStatus {
55 fn with_twin(mut self, status: TwinStatus) -> Self {
56 self.twin = status;
57 self
58 }
59}
60
61pub struct CuTwinBuilder<A, R> {
66 rx: R,
67 feedback_tx: Option<Box<dyn crate::CuFeedbackTx>>,
68 log_base: Option<PathBuf>,
69 log_rollover: Option<usize>,
70 frame_capacity: NonZeroUsize,
71 log_capacity: NonZeroUsize,
72 replay_capacity: NonZeroUsize,
73 slab_bytes: usize,
74 section_bytes: usize,
75 receiver_limits: SessionRouterLimits,
76 reconstruct: bool,
77 app: PhantomData<fn() -> A>,
78}
79
80impl<A: LiveReplay, R: CuStreamRx + 'static> CuTwinBuilder<A, R> {
81 pub fn with_feedback<T: crate::CuFeedbackTx + 'static>(mut self, tx: T) -> Self {
83 self.feedback_tx = Some(Box::new(tx));
84 self
85 }
86
87 pub fn with_log_path(mut self, path: impl AsRef<Path>) -> Self {
88 self.log_base = Some(path.as_ref().to_path_buf());
89 self
90 }
91
92 pub fn with_log_rollover(mut self, bytes: usize) -> Self {
96 self.log_rollover = Some(bytes);
97 self
98 }
99
100 pub fn with_slab_size(mut self, bytes: usize) -> Self {
109 self.slab_bytes = bytes;
110 self
111 }
112
113 pub fn with_section_size(mut self, bytes: usize) -> Self {
126 self.section_bytes = bytes;
127 self
128 }
129
130 pub fn with_receiver_limits(mut self, limits: SessionRouterLimits) -> Self {
145 self.receiver_limits = limits;
146 self
147 }
148
149 pub fn with_replay_capacity(mut self, capacity: NonZeroUsize) -> Self {
161 self.replay_capacity = capacity;
162 self
163 }
164
165 pub fn with_log_capacity(mut self, capacity: NonZeroUsize) -> Self {
174 self.log_capacity = capacity;
175 self
176 }
177
178 pub fn with_frame_capacity(mut self, capacity: NonZeroUsize) -> Self {
190 self.frame_capacity = capacity;
191 self
192 }
193
194 pub fn archive_only(mut self) -> Self {
196 self.reconstruct = false;
197 self
198 }
199
200 pub fn spawn(self) -> CuResult<(CuTwin<A>, CuTwinReader<A>)> {
201 validate_sizes(self.slab_bytes, self.section_bytes, self.receiver_limits)?;
202 let mut router =
204 SessionRouter::<1128, 64, 64>::new(self.receiver_limits).map_err(stream_error)?;
205 if self
206 .log_rollover
207 .is_some_and(|bytes| bytes <= self.section_bytes || !bytes.is_multiple_of(512))
208 {
209 return Err(CuError::from(
210 "Twin log rollover capacity must exceed the configured section size and be a multiple of 512 bytes",
211 ));
212 }
213 let path = self
214 .log_base
215 .ok_or_else(|| CuError::from("Copper twin requires a log path"))?;
216 if cu29_runtime::replay::first_slab_path(&path)?.exists() || path.exists() {
217 return Err(CuError::from(
218 "Copper twin archive already exists; choose a fresh log path",
219 ));
220 }
221 if let Some(parent) = path
222 .parent()
223 .filter(|parent| !parent.as_os_str().is_empty())
224 {
225 std::fs::create_dir_all(parent)
226 .map_err(|e| CuError::new_with_cause("Create twin log directory", e))?;
227 }
228 let (publisher, reader) = telemetry_channel(self.frame_capacity, CuTwinStatus::default());
229 let (mut log_publisher, log_reader) = telemetry_channel(self.log_capacity, ());
230 let stop = Arc::new(AtomicBool::new(false));
231 let stopping = stop.clone();
232 let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
233 let worker = thread::Builder::new()
234 .name("copper-twin-receiver".into())
235 .spawn(move || {
236 let mut status = CuTwinStatus::default();
237 let (replay, mut publisher) = if self.reconstruct {
238 match TwinWorker::spawn::<A>(self.replay_capacity, publisher) {
239 Ok(replay) => (Some(replay), None),
240 Err(error) => {
241 let _ = ready_tx.send(Err(error.clone()));
242 return Err(error);
243 }
244 }
245 } else {
246 (None, Some(publisher))
247 };
248 let mut publish_status = |status| {
249 if let Some(replay) = &replay {
250 replay.set_status(status);
251 }
252 if let Some(publisher) = &mut publisher {
253 publisher.set_status(status);
254 }
255 };
256 let mut archive = None;
257 let result = (|| {
258 let _ = ready_tx.send(Ok(()));
259 let mut rx = self.rx;
260 let mut feedback_tx = self.feedback_tx;
261 let feedback_clock = cu29_clock::RobotClock::new();
262 let receiver_id = crate::new_session_id();
263 let mut reporter = None;
264 let mut feedback_packet = [0; crate::feedback::FEEDBACK_BUFFER_BYTES];
265 let mut packet = [0; 1200];
266 while !stopping.load(Ordering::Acquire) {
267 if let Some(len) = rx
268 .try_recv(&mut packet)
269 .map_err(|e| CuError::from(format!("Twin receive: {e:?}")))?
270 {
271 let bytes = packet.get(..len).ok_or_else(|| {
272 CuError::from("Transport returned an invalid packet length")
273 })?;
274 status.last_packet = Some(Instant::now());
275 router
276 .receive_datagram(bytes, |event| {
277 if matches!(&event, SessionEvent::Object { record, .. } if record.decoded().kind != crate::RecordKind::StructuredLog) {
281 return Ok(());
282 }
283 if let SessionEvent::Manifest(manifest) = &event {
284 if manifest.manifest().context.mission_index != A::MISSION_INDEX {
285 return Err(crate::Error::InvalidConfig("twin requires the matching sender mission"));
286 }
287 status.identity = Some(manifest.manifest().identity);
288 if feedback_tx.is_some() {
289 reporter = crate::feedback::FeedbackReporter::new(
290 manifest.manifest(),
291 receiver_id,
292 feedback_clock.now(),
293 );
294 }
295 let mut builder = UnifiedLoggerBuilder::new()
296 .file_base_name(&path)
297 .preallocated_size(self.log_rollover.unwrap_or(self.slab_bytes))
298 .write(true)
299 .create(true);
300 if let Some(bytes) = self.log_rollover {
301 builder = builder.rollover(bytes);
302 }
303 let logger = builder.build()
304 .map_err(|e| crate::Error::Codec(e.to_string()))?;
305 let UnifiedLogger::Write(mut logger) = logger else {
306 unreachable!()
307 };
308 A::seal_archive_metadata(&mut logger)
309 .map_err(|e| crate::Error::Codec(e.to_string()))?;
310 archive = Some(CaptureArchive::<A::DataSet>::new_with_logger(
311 manifest, self.section_bytes, logger,
312 )?);
313 }
314 let writer =
315 archive.as_mut().ok_or(crate::Error::InvalidConfig(
316 "capture arrived before its manifest",
317 ))?;
318 let capture = writer.accept(&event)?;
319 if let Some(entry) = writer.take_structured_log() {
320 status.structured_logs += 1;
321 if let SessionEvent::Object { identity, record } = &event {
322 log_publisher.publish(ReceivedStructuredLog {
323 identity: *identity, sequence: record.decoded().object_id, entry,
324 });
325 }
326 }
327 if let Some(capture) = &capture {
328 status.latest = Some(capture.copperlist.id);
329 status.archived += 1;
330 status.state = CuTwinRecordingState::Recording;
331 }
332 if let Some(replay) = &replay {
333 replay.accept(&event, capture)?;
334 }
335 match event {
336 SessionEvent::Gap { .. } => status.gaps += 1,
337 SessionEvent::VerifiedRecoveryPoint {
338 recovery_point,
339 ..
340 } => {
341 status.recovery_point =
342 Some(recovery_point.copperlist_id)
343 }
344 _ => {}
345 }
346 Ok::<(), crate::Error>(())
347 })
348 .map_err(|e| CuError::from(format!("Twin receive: {e}")))?;
349 status.packets = router.stats().datagrams_seen;
350 publish_status(status);
351 } else {
352 thread::park_timeout(POLL_INTERVAL);
353 }
354 if let (Some(reporter), Some(tx), Some(identity)) =
355 (&mut reporter, &mut feedback_tx, status.identity)
356 && let Some(mut counters) = router.feedback_counters(identity)
357 {
358 counters.latest_copperlist = status.latest;
359 if let Some(report) = reporter.report(feedback_clock.now(), counters) {
360 let len = report
361 .encode_into(&mut feedback_packet)
362 .map_err(stream_error)?;
363 match tx.try_send_feedback(&feedback_packet[..len]) {
364 Ok(()) => status.feedback_reports_sent += 1,
365 Err(crate::CuStreamTxError::WouldBlock) => {
366 status.feedback_reports_dropped += 1
367 }
368 Err(crate::CuStreamTxError::Failed(_)) => {
369 feedback_tx = None;
370 status.feedback_failed = true;
371 }
372 }
373 publish_status(status);
374 }
375 }
376 }
377 if let Some(archive) = archive.take() {
378 archive.finish().map_err(stream_error)?;
379 }
380 Ok(())
381 })();
382 status.state = if result.is_ok() {
383 CuTwinRecordingState::Closed
384 } else {
385 CuTwinRecordingState::Failed
386 };
387 publish_status(status);
388 if let Some(replay) = replay {
390 status.twin = replay.finish()?;
391 }
392 result.map(|()| status)
393 })
394 .map_err(|e| CuError::new_with_cause("Start Copper twin", e))?;
395 if let Err(error) = ready_rx
396 .recv()
397 .map_err(|_| CuError::from("Copper twin initialization failed"))
398 .and_then(|ready| ready)
399 {
400 stop.store(true, Ordering::Release);
401 let _ = worker.join();
402 return Err(error);
403 }
404 Ok((
405 CuTwin {
406 stop,
407 worker: Some(worker),
408 final_status: None,
409 log_reader: Some(log_reader),
410 app: PhantomData,
411 },
412 reader,
413 ))
414 }
415}
416
417fn stream_error(error: crate::Error) -> CuError {
418 CuError::from(error.to_string())
419}
420
421pub type CuTwinReader<A> = TelemetryReader<TwinFrame<<A as LiveReplay>::DataSet>, CuTwinStatus>;
423
424#[derive(Debug)]
426pub struct ReceivedStructuredLog {
427 pub identity: StreamIdentity,
428 pub sequence: u64,
429 pub entry: cu29_log::CuLogEntry,
430}
431
432pub type CuTwinLogReader = TelemetryReader<ReceivedStructuredLog, ()>;
434
435fn validate_sizes(
436 slab_bytes: usize,
437 section_bytes: usize,
438 limits: SessionRouterLimits,
439) -> CuResult<()> {
440 let header = usize::from(cu29_unifiedlog::SECTION_HEADER_COMPACT_SIZE);
441 if slab_bytes < 1024 || !slab_bytes.is_multiple_of(header) {
442 return Err(CuError::from(
443 "Twin slab size must be at least 1024 bytes and a multiple of 512",
444 ));
445 }
446 if section_bytes <= header
447 || !section_bytes.is_multiple_of(header)
448 || section_bytes > slab_bytes
449 {
450 return Err(CuError::from(
451 "Twin section size must exceed 512 bytes, be a multiple of 512, and fit in its slab",
452 ));
453 }
454 if section_bytes - header > u32::MAX as usize {
455 return Err(CuError::from("Twin section payload size exceeds u32"));
456 }
457 if limits.max_sessions != 1 {
458 return Err(CuError::from(
459 "Twin receiver limits must allow exactly one sender session",
460 ));
461 }
462 if limits.equation_capacity > 64 || usize::from(limits.finite_objects.max_symbol_size) > 1128 {
463 return Err(CuError::from(
464 "Twin receiver limits exceed the 1128-byte-symbol, 64-equation FEC profile",
465 ));
466 }
467 crate::object::validate_limits(limits.finite_objects).map_err(stream_error)?;
468 if limits.max_record_bytes > u32::MAX as usize {
469 return Err(CuError::from("Twin maximum record size exceeds u32"));
470 }
471 let object_bytes = usize::try_from(limits.finite_objects.max_object_bytes)
472 .map_err(|_| CuError::from("Twin maximum object size exceeds usize"))?;
473 let required = limits
474 .max_record_bytes
475 .max(object_bytes)
476 .checked_add(CONTINUITY_OVERHEAD)
477 .and_then(|bytes| bytes.checked_add(header))
478 .ok_or_else(|| CuError::from("Twin section size requirement overflow"))?;
479 if section_bytes < required {
480 return Err(CuError::from(format!(
481 "Twin section size is {section_bytes} bytes; configured record/object bounds require at least {required} bytes including header and continuity envelope"
482 )));
483 }
484 Ok(())
485}
486
487pub struct CuTwin<A: LiveReplay> {
491 stop: Arc<AtomicBool>,
492 worker: Option<JoinHandle<CuResult<CuTwinStatus>>>,
493 final_status: Option<CuTwinStatus>,
494 log_reader: Option<CuTwinLogReader>,
495 app: PhantomData<fn() -> A>,
496}
497
498impl<A: LiveReplay> CuTwin<A> {
499 pub fn builder<R: CuStreamRx + 'static>(rx: R) -> CuTwinBuilder<A, R> {
500 CuTwinBuilder {
501 rx,
502 feedback_tx: None,
503 log_base: None,
504 log_rollover: None,
505 frame_capacity: FRAME_CAPACITY,
506 log_capacity: FRAME_CAPACITY,
507 replay_capacity: REPLAY_CAPACITY,
508 slab_bytes: SLAB_BYTES,
509 section_bytes: SECTION_BYTES,
510 receiver_limits: SessionRouterLimits::default(),
511 reconstruct: true,
512 app: PhantomData,
513 }
514 }
515
516 pub fn take_log_reader(&mut self) -> Option<CuTwinLogReader> {
519 self.log_reader.take()
520 }
521
522 pub fn stop(&mut self) -> CuResult<CuTwinStatus> {
523 self.stop.store(true, Ordering::Release);
524 if let Some(worker) = self.worker.take() {
525 worker.thread().unpark();
526 self.final_status = Some(
527 worker
528 .join()
529 .map_err(|_| CuError::from("Copper twin receiver panicked"))??,
530 );
531 }
532 self.final_status
533 .ok_or_else(|| CuError::from("Copper twin stopped after a worker failure"))
534 }
535}
536
537impl<A: LiveReplay> Drop for CuTwin<A> {
538 fn drop(&mut self) {
539 let _ = self.stop();
540 }
541}