feat(background): runner PTY B8 + boucle sink fermée + refactor point-2
Livre le lot backend B8 des tâches de fond : - infrastructure : runner de commandes concret (CommandBackgroundRunner) sur le port BackgroundTaskRunner, tail borné (bounded_tail/BoundedTail), éclatement du module background_task en sous-modules (mod/runner/tail, sink extrait de l'ancien background_task.rs). - application : nouveau module background exposant les cas d'usage SpawnBackgroundCommand, CancelBackgroundTask, RetryBackgroundTask et le port BackgroundCommandArchive. - domain : refactor point-2 de l'arbitrage Architect — sortie du trait BackgroundCommandArchive de la couche domaine vers application. - app-tauri : câblage runtime (commands, dto, state, lib) des commandes spawn/cancel/retry et de la boucle de complétion sink fermée en composition root. Build workspace + tests application/infrastructure verts (QA). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
149
crates/infrastructure/src/background_task/tail.rs
Normal file
149
crates/infrastructure/src/background_task/tail.rs
Normal file
@ -0,0 +1,149 @@
|
||||
//! UTF-8-safe bounded output tail (B8 sub-task 1).
|
||||
//!
|
||||
//! A background command can print megabytes; the completion contract only keeps a
|
||||
//! bounded *tail* (the most recent bytes). [`BoundedTail`] is a byte ring buffer
|
||||
//! capped at construction; [`bounded_tail`] renders the retained bytes as a
|
||||
//! `String`, trimming any partial UTF-8 sequence at the front so a multi-byte
|
||||
//! character never gets sliced by the cap.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use domain::background_task::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES;
|
||||
|
||||
/// Default tail cap when `IDEA_BG_TAIL_BYTES` is unset (8 KiB).
|
||||
const DEFAULT_TAIL_BYTES: usize = 8 * 1024;
|
||||
/// Floor so a hostile/typo'd env value can't disable the tail entirely.
|
||||
const MIN_TAIL_BYTES: usize = 1024;
|
||||
|
||||
/// Resolves the tail cap in bytes from `IDEA_BG_TAIL_BYTES`.
|
||||
///
|
||||
/// Defaults to 8 KiB, floored at 1 KiB, and clamped to the domain maximum
|
||||
/// ([`BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES`], 16 KiB) so the persisted result
|
||||
/// always validates.
|
||||
#[must_use]
|
||||
pub fn tail_cap_bytes() -> usize {
|
||||
std::env::var("IDEA_BG_TAIL_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(DEFAULT_TAIL_BYTES)
|
||||
.clamp(MIN_TAIL_BYTES, BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES)
|
||||
}
|
||||
|
||||
/// A byte ring buffer that retains only the last `cap` bytes pushed into it.
|
||||
#[derive(Debug)]
|
||||
pub struct BoundedTail {
|
||||
buf: VecDeque<u8>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
impl BoundedTail {
|
||||
/// Builds a tail retaining at most `cap` bytes (a `cap` of `0` retains
|
||||
/// nothing but never panics).
|
||||
#[must_use]
|
||||
pub fn with_cap(cap: usize) -> Self {
|
||||
Self {
|
||||
buf: VecDeque::new(),
|
||||
cap,
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a chunk, dropping the oldest bytes past the cap.
|
||||
pub fn push(&mut self, chunk: &[u8]) {
|
||||
if self.cap == 0 {
|
||||
return;
|
||||
}
|
||||
// If the incoming chunk alone exceeds the cap, only its tail can survive.
|
||||
let start = chunk.len().saturating_sub(self.cap);
|
||||
self.buf.extend(&chunk[start..]);
|
||||
let overflow = self.buf.len().saturating_sub(self.cap);
|
||||
if overflow > 0 {
|
||||
self.buf.drain(0..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether any byte is retained.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buf.is_empty()
|
||||
}
|
||||
|
||||
/// Renders the retained bytes as a UTF-8 string, dropping a leading partial
|
||||
/// multi-byte sequence left dangling by the cap. Returns `None` when empty.
|
||||
#[must_use]
|
||||
pub fn to_tail_string(&self) -> Option<String> {
|
||||
if self.buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = self.buf.iter().copied().collect();
|
||||
Some(bounded_tail(&bytes, self.cap))
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the last `cap` bytes of `bytes` as a lossless-at-the-boundary
|
||||
/// `String`.
|
||||
///
|
||||
/// The slice is taken from the end, then any partial UTF-8 sequence at the *front*
|
||||
/// (a continuation byte with no lead byte, because the cap cut mid-character) is
|
||||
/// skipped so the result is valid UTF-8 without replacement characters at the
|
||||
/// seam. Invalid bytes in the interior are still replaced lossily.
|
||||
#[must_use]
|
||||
pub fn bounded_tail(bytes: &[u8], cap: usize) -> String {
|
||||
let start = bytes.len().saturating_sub(cap);
|
||||
let mut slice = &bytes[start..];
|
||||
// Only realign when we actually truncated the front (start > 0): otherwise a
|
||||
// legitimately leading continuation byte is just invalid input, handled below.
|
||||
if start > 0 {
|
||||
let mut skip = 0;
|
||||
while skip < slice.len() && is_utf8_continuation(slice[skip]) {
|
||||
skip += 1;
|
||||
}
|
||||
slice = &slice[skip..];
|
||||
}
|
||||
String::from_utf8_lossy(slice).into_owned()
|
||||
}
|
||||
|
||||
/// Whether `b` is a UTF-8 continuation byte (`10xxxxxx`).
|
||||
const fn is_utf8_continuation(b: u8) -> bool {
|
||||
b & 0b1100_0000 == 0b1000_0000
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_only_the_last_cap_bytes() {
|
||||
let mut tail = BoundedTail::with_cap(4);
|
||||
tail.push(b"abcdef");
|
||||
assert_eq!(tail.to_tail_string().as_deref(), Some("cdef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_bigger_than_cap_keeps_tail() {
|
||||
let mut tail = BoundedTail::with_cap(3);
|
||||
tail.push(b"0123456789");
|
||||
assert_eq!(tail.to_tail_string().as_deref(), Some("789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tail_is_none() {
|
||||
let tail = BoundedTail::with_cap(8);
|
||||
assert!(tail.is_empty());
|
||||
assert_eq!(tail.to_tail_string(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_multibyte_char_is_trimmed_at_front() {
|
||||
// "é" is 0xC3 0xA9. Cap of 1 would keep only the trailing 0xA9 (a lone
|
||||
// continuation byte); it must be dropped, yielding an empty string.
|
||||
let bytes = "é".as_bytes();
|
||||
assert_eq!(bounded_tail(bytes, 1), "");
|
||||
// Cap of 2 keeps the whole character.
|
||||
assert_eq!(bounded_tail(bytes, 2), "é");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interior_is_preserved() {
|
||||
assert_eq!(bounded_tail("héllo".as_bytes(), 100), "héllo");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user