shep_core/barks.rs
1//! `barks.jsonl`: the size-capped ring of fired alerts (spec §10.4).
2//!
3//! One [`Bark`] per line, appended by two writers in two different
4//! processes — the bark dog when a rule fires, and the shepherd itself
5//! when an enabled dog exhausts its restart budget — and read by a third,
6//! `shep barks`. [`append`] keeps the file under a byte cap by evicting
7//! whole lines oldest-first, rewriting the survivors plus the new record
8//! to a sibling temp file and `rename`ing it over the original — the same
9//! atomic-replace shape `shep-daemon`'s `snapshot::write_atomic` uses —
10//! rather than truncating in place, so a writer that dies mid-rewrite
11//! never leaves the reader a fragment.
12//!
13//! [`read`] is the forgiving half: a line that will not parse — a
14//! partially-written record from a writer that died mid-append, or a
15//! record from a future shep — costs the reader that one record, not the
16//! whole history. This file is read during an incident; refusing the
17//! whole ring over one bad line would be the wrong failure mode.
18//!
19//! Two writer processes is also why [`append`] takes an advisory lock on
20//! a sibling `<path>.lock` and holds it across the whole
21//! read-evict-rewrite-rename sequence. Without it the two writers
22//! interleave read-modify-write and the later `rename` silently discards
23//! every record the other appended in between — reproduced, not
24//! theorised: two processes appending 200 records each left 200 of the
25//! expected 400 in the file. Nothing about the atomic-replace shape
26//! prevents that; atomicity buys the reader a whole file, not the writer
27//! a whole transaction.
28//!
29//! Lives in shep-core, not shep-daemon, because it has two writers that
30//! are two different processes (the shepherd and the bark dog) and
31//! neither is the other's crate — one shared cap implementation, or the
32//! two writers evict differently, and that is exactly the kind of drift
33//! nobody watches until an incident.
34
35use core::fmt;
36use std::io::Write as _;
37use std::path::Path;
38
39use serde::{Deserialize, Serialize};
40
41/// Cap the ring keeps itself under when nobody configured one.
42pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024;
43
44/// One fired alert, as it lands in `$SHEP_HOME/barks.jsonl`.
45///
46/// One JSON object per line, because the file is appended to by two
47/// writers (the bark dog when a rule fires, and the shepherd when an
48/// enabled dog exhausts its budget) and read by a third (`shep barks`).
49/// A line-delimited format is the one shape where an interrupted write
50/// costs the reader one record instead of the file.
51///
52/// `Debug` is derived, not redacted. Every field here is shep's own
53/// prose or a config key — never a sink's target — so printing a `Bark`
54/// is safe, and it must stay that way: a field that ever carried a
55/// webhook URL or token would need its own redacted `Debug` (IR-41) the
56/// day it lands, and this comment is the tripwire for that review.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Bark {
59 /// Unix millis when the alert fired.
60 pub at_ms: u64,
61 /// The rule that fired, or `daemon` when the shepherd wrote this
62 /// itself.
63 pub rule: String,
64 /// What it is about: a sheep's name, or a dog's.
65 pub subject: String,
66 /// The human-readable line. Plain English, no theme — this is read
67 /// during an incident.
68 pub message: String,
69 /// Which sinks the alert was delivered to, and whether each took it.
70 /// Empty when the shepherd wrote the record itself: it has no sinks
71 /// and no webhook code, and says so by carrying none.
72 pub sinks: Vec<SinkOutcome>,
73}
74
75/// What one sink made of one alert.
76///
77/// Names the sink by its `[dog.bark.sinks]` config key, never by its
78/// webhook URL or bearer token — that is what keeps this type, and
79/// [`Bark`] alongside it, safe to print with a derived `Debug`.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct SinkOutcome {
82 /// The sink's name from `[dog.bark.sinks]`.
83 pub sink: String,
84 /// `None` when it was delivered; the failure otherwise.
85 pub error: Option<String>,
86}
87
88/// Error type returned by [`append`] and [`read`].
89///
90/// Wraps `io::Error`/`serde_json::Error` directly rather than
91/// stringifying them (same reasoning as `shep-daemon`'s
92/// `SnapshotError`) so callers keep the underlying diagnostic via
93/// [`core::error::Error::source`] — the cost is that this enum cannot
94/// derive `Clone`/`PartialEq`/`Eq` (IR-19's documented exception for
95/// variants wrapping `io::Error`).
96///
97/// `#[non_exhaustive]`: shep-core is a published library and this enum is
98/// reachable from it, so a third failure shape — a ring whose on-disk format
99/// this build does not recognise, say — must not break an out-of-tree
100/// consumer's `match` (IR-20).
101#[non_exhaustive]
102#[derive(Debug)]
103pub enum BarkError {
104 /// The ring file could not be read, written, or replaced.
105 Io(std::io::Error),
106 /// A [`Bark`] could not be serialized to JSON.
107 Encode(serde_json::Error),
108}
109
110impl fmt::Display for BarkError {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Self::Io(err) => write!(f, "bark ring I/O failed: {err}"),
114 Self::Encode(err) => write!(f, "bark record failed to serialize: {err}"),
115 }
116 }
117}
118
119impl core::error::Error for BarkError {
120 fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
121 match self {
122 Self::Io(err) => Some(err),
123 Self::Encode(err) => Some(err),
124 }
125 }
126}
127
128impl From<std::io::Error> for BarkError {
129 fn from(source: std::io::Error) -> Self {
130 Self::Io(source)
131 }
132}
133
134impl From<serde_json::Error> for BarkError {
135 fn from(source: serde_json::Error) -> Self {
136 Self::Encode(source)
137 }
138}
139
140/// Appends `bark` to `path`, evicting oldest-first to keep the file under
141/// `max_bytes`.
142///
143/// Eviction is oldest-out by whole lines: the file is rewritten with a
144/// prefix of its lines dropped, atomically, so a reader never sees a
145/// truncated one. A single record larger than `max_bytes` is written
146/// anyway and leaves the file over the cap — the alternative is silently
147/// dropping the alert that was too interesting to fit.
148///
149/// Serialized against other appenders — in this process or any other — by
150/// an advisory lock on a sibling `<path>.lock`, held across the whole
151/// read-modify-rename. Two writers without it lose each other's records
152/// outright; see this module's own doc. Concurrent [`read`]s are not
153/// blocked and do not need to be: the ring is only ever replaced whole,
154/// by `rename`.
155///
156/// # Errors
157/// - [`BarkError::Io`] — the file could not be read, written, or
158/// replaced, or the lock beside it could not be taken.
159/// - [`BarkError::Encode`] — the record could not be serialized.
160pub fn append(path: &Path, bark: &Bark, max_bytes: u64) -> Result<(), BarkError> {
161 // Held until this function returns, so the read below and the rename
162 // at the end are one transaction as far as any other writer is
163 // concerned — see [`RingLock`] for why the lock is not on `path`.
164 let _lock = RingLock::acquire(path)?;
165
166 let mut lines = read_lines(path)?;
167 let new_line = serde_json::to_string(bark)?;
168 lines.push(new_line);
169
170 // Oldest-out: drop the front line until the ring fits under the cap,
171 // or only the record just appended is left — see this function's own
172 // doc for why a lone oversized record is kept rather than dropped.
173 loop {
174 if lines.len() <= 1 || ring_bytes(&lines) <= max_bytes {
175 break;
176 }
177 lines.remove(0);
178 }
179
180 write_ring(path, &lines)
181}
182
183/// Reads every bark in `path`, oldest first, skipping any line that will
184/// not parse.
185///
186/// A line that will not parse is a partially-written record from a writer
187/// that died mid-append, or a record from a future shep. Neither is a
188/// reason to refuse the whole history during an incident, which is the one
189/// time this file is read.
190///
191/// # Errors
192/// - [`BarkError::Io`] — the file exists and could not be read. A missing
193/// file is `Ok(Vec::new())`: no barks yet is not a fault.
194pub fn read(path: &Path) -> Result<Vec<Bark>, BarkError> {
195 match std::fs::read_to_string(path) {
196 Ok(text) => Ok(text
197 .lines()
198 .filter_map(|line| serde_json::from_str(line).ok())
199 .collect()),
200 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
201 Err(err) => Err(BarkError::Io(err)),
202 }
203}
204
205/// `path`'s existing lines, raw and unparsed, or an empty ring if the
206/// file does not exist yet.
207///
208/// Deliberately does not parse: eviction operates on whole lines exactly
209/// as they sit on disk, so a line a future shep wrote (or a fragment a
210/// dead writer left) still counts toward the byte cap and still survives
211/// an eviction it does not trigger — [`read`], not this, is where an
212/// unparseable line is finally dropped.
213fn read_lines(path: &Path) -> Result<Vec<String>, BarkError> {
214 match std::fs::read_to_string(path) {
215 Ok(text) => Ok(text.lines().map(str::to_owned).collect()),
216 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
217 Err(err) => Err(BarkError::Io(err)),
218 }
219}
220
221/// Total on-disk size, in bytes, if `lines` were written one per line
222/// (each line plus its trailing `\n`).
223fn ring_bytes(lines: &[String]) -> u64 {
224 lines.iter().map(|line| line.len() as u64 + 1).sum()
225}
226
227/// Rewrites `path` to hold exactly `lines`: the new content lands in a
228/// uniquely-named sibling temp file, is `fsync`ed, then `rename`d over
229/// `path` — the same shape `snapshot::write_atomic` uses, so an
230/// interrupted write leaves the original file exactly as it was rather
231/// than a fragment (this module's own doc: rewrite-and-replace, not
232/// truncate-in-place).
233///
234/// The name is unique per call, not a fixed `<path>.tmp`. A shared temp
235/// name is not merely untidy: two writers racing on it had one process's
236/// `rename` consume the other's staging file, and the loser died with
237/// `ENOENT` renaming a path that no longer existed. [`RingLock`] already
238/// keeps two appenders apart, so this is the second lock on the same door
239/// — deliberately, because it is the half that survives a caller who ever
240/// reaches `write_ring` by another route.
241fn write_ring(path: &Path, lines: &[String]) -> Result<(), BarkError> {
242 let parent = path.parent().unwrap_or_else(|| Path::new("."));
243 let mut tmp = crate::atomic_file::create_staging_file(parent, "barks", ".tmp")?;
244
245 for line in lines {
246 tmp.write_all(line.as_bytes())?;
247 tmp.write_all(b"\n")?;
248 }
249 tmp.as_file().sync_all()?;
250
251 // `persist` is `rename(2)`. On failure the `NamedTempFile` comes back
252 // inside the error and its `Drop` removes the staging file, so a
253 // failed replace does not leave one behind.
254 tmp.persist(path).map_err(|err| BarkError::Io(err.error))?;
255
256 // The `sync_all` above made the CONTENTS durable; this makes the rename
257 // that published them durable. See `shep_core::atomic_file`.
258 crate::atomic_file::sync_dir(parent)?;
259 Ok(())
260}
261
262/// An exclusive advisory lock over one bark ring, held for as long as the
263/// value lives and released when it drops (including on an early `?`, and
264/// by the kernel if the process dies holding it).
265///
266/// The lock is on a sibling `<path>.lock`, never on the ring itself, and
267/// that is the whole design decision: `append` finishes by `rename`ing a
268/// new file over `path`, which replaces the inode. A lock taken on the
269/// ring would be a lock on an inode that the very next successful append
270/// unlinks — the next writer would open the *new* inode, find it
271/// unlocked, and the two would be excluding nothing. The lock file is
272/// never renamed, never rewritten, and never read; it exists only to be
273/// an inode with a stable identity, and it is left on disk between
274/// appends on purpose so both writers keep agreeing on which one it is.
275struct RingLock {
276 /// `flock(2)` is released by this handle's `Drop`. Named with a
277 /// leading underscore because it is held, never read.
278 #[cfg(unix)]
279 _flock: nix::fcntl::Flock<std::fs::File>,
280 /// The lock file, opened with `share_mode(0)` so no other handle —
281 /// same-process or not, read or write — can open it while this one is
282 /// live. Released by this handle's `Drop`, the same role `_flock` plays
283 /// on unix. Named with a leading underscore because it is held, never
284 /// read.
285 #[cfg(windows)]
286 _handle: std::fs::File,
287}
288
289impl RingLock {
290 /// Blocks until this process holds the ring's lock exclusively.
291 ///
292 /// # Errors
293 /// The lock file could not be created beside `path`, or `flock` failed
294 /// for a reason other than contention (contention blocks rather than
295 /// failing).
296 #[cfg(unix)]
297 fn acquire(path: &Path) -> std::io::Result<Self> {
298 use nix::fcntl::{Flock, FlockArg};
299 use std::os::unix::fs::OpenOptionsExt as _;
300
301 let file = std::fs::OpenOptions::new()
302 .write(true)
303 .create(true)
304 .truncate(false)
305 .mode(crate::atomic_file::OWNER_ONLY_FILE_MODE)
306 .open(lock_path(path))?;
307
308 // `LockExclusive` blocks; the non-blocking variant would need a
309 // retry loop and a deadline, and an append that waits its turn is
310 // exactly the behaviour wanted here.
311 Flock::lock(file, FlockArg::LockExclusive)
312 .map(|flock| Self { _flock: flock })
313 .map_err(|(_file, errno)| std::io::Error::from(errno))
314 }
315
316 /// Blocks until this process holds the ring's lock exclusively.
317 ///
318 /// `share_mode(0)` gives the same exclusivity `flock(2)` does through a
319 /// different door — opening the lock file with every share flag cleared
320 /// means no other handle, another process's or this one's, read or
321 /// write, can be opened on it while this handle lives. That is
322 /// mandatory (enforced by the OS on every open) rather than merely
323 /// advisory, so it is if anything a stronger guarantee than the unix
324 /// arm's.
325 ///
326 /// What it does not give is a blocking wait: a contended open fails at
327 /// once with `ERROR_SHARING_VIOLATION` rather than parking the thread
328 /// the way `FlockArg::LockExclusive` does, so this polls on a short
329 /// sleep until the open succeeds. That is the one real behavioural
330 /// difference between the two arms, and it is why "contention blocks
331 /// rather than failing" stays true here by a retry loop rather than by
332 /// the kernel.
333 ///
334 /// Identical in shape to [`KvLock::acquire`](crate::kv)'s Windows arm,
335 /// deliberately — the two guard the same kind of file in the same
336 /// directory, and a reader who has understood one should not have to
337 /// re-derive the other.
338 ///
339 /// # Errors
340 /// The lock file could not be created beside `path`, or the open failed
341 /// for a reason other than sharing contention (contention retries
342 /// rather than failing).
343 #[cfg(windows)]
344 fn acquire(path: &Path) -> std::io::Result<Self> {
345 use std::os::windows::fs::OpenOptionsExt as _;
346
347 /// Windows' `ERROR_SHARING_VIOLATION`: another handle already holds
348 /// share access this open's `share_mode(0)` denies. Hardcoded
349 /// rather than pulled from `windows-sys` — this crate has no
350 /// Windows-only dependency today, and one well-known, stable error
351 /// code does not earn it one.
352 const ERROR_SHARING_VIOLATION: i32 = 32;
353
354 /// How long a contended retry sleeps before trying again. Short
355 /// enough that a lock held for one `append`'s duration (a read, a
356 /// write, a rename) costs this loop only a few iterations, long
357 /// enough not to spin the CPU while it waits.
358 const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2);
359
360 let lock_path = lock_path(path);
361 loop {
362 match std::fs::OpenOptions::new()
363 .write(true)
364 .create(true)
365 .truncate(false)
366 .share_mode(0)
367 .open(&lock_path)
368 {
369 Ok(handle) => return Ok(Self { _handle: handle }),
370 Err(error) if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION) => {
371 std::thread::sleep(RETRY_INTERVAL);
372 }
373 Err(error) => return Err(error),
374 }
375 }
376 }
377}
378
379/// The lock file that guards `path`: its own name with `.lock` appended,
380/// so it sits in `$SHEP_HOME` next to the ring and inherits that
381/// directory's `0700`.
382///
383/// `cfg(any(unix, windows))` alongside its two callers —
384/// [`RingLock::acquire`] names a real lock file on both platforms now, unix
385/// through `flock(2)` and windows through an exclusive `share_mode(0)` open.
386#[cfg(any(unix, windows))]
387fn lock_path(path: &Path) -> std::path::PathBuf {
388 let mut name = path
389 .file_name()
390 .map(std::ffi::OsStr::to_os_string)
391 .unwrap_or_default();
392 name.push(".lock");
393 path.parent().unwrap_or_else(|| Path::new(".")).join(name)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 /// A representative fired alert. `at_ms` is a caller-chosen tag, not a
401 /// real timestamp — tests use it to tell records apart, not to
402 /// exercise time handling.
403 fn bark_for(subject: &str, at_ms: u64) -> Bark {
404 Bark {
405 at_ms,
406 rule: "watchdog".to_string(),
407 subject: subject.to_string(),
408 message: "restart budget exhausted".to_string(),
409 sinks: vec![SinkOutcome {
410 sink: "discord".to_string(),
411 error: None,
412 }],
413 }
414 }
415
416 /// The serialized length (plus its trailing newline) of one
417 /// `bark_for`-shaped line, measured rather than hard-coded — a
418 /// constant that happened to equal the implementation's own byte
419 /// count would pass for any cap, which is the assertion-against-the-
420 /// same-constant shape this project has shipped before.
421 fn one_bark_len() -> u64 {
422 let line = serde_json::to_string(&bark_for("second", 1)).unwrap();
423 line.len() as u64 + 1
424 }
425
426 /// The eviction, which is the whole reason this is a ring and not an
427 /// append. A cap the test never reaches leaves an append-only file with
428 /// extra code, so the cap here is deliberately small enough that the
429 /// third write MUST evict — and the assertion names the surviving
430 /// subject rather than counting lines, so a ring that evicted the
431 /// NEWEST record would fail here rather than pass on the count.
432 #[test]
433 fn the_ring_drops_the_oldest_bark_to_stay_under_its_cap() {
434 let dir = tempfile::tempdir().unwrap();
435 let path = dir.path().join("barks.jsonl");
436 let cap = 2 * one_bark_len();
437
438 for (i, subject) in ["first", "second", "third"].iter().enumerate() {
439 append(&path, &bark_for(subject, i as u64), cap).unwrap();
440 }
441
442 let barks = read(&path).unwrap();
443 let subjects: Vec<&str> = barks.iter().map(|b| b.subject.as_str()).collect();
444 assert_eq!(subjects, ["second", "third"], "oldest out, newest kept");
445 assert!(
446 std::fs::metadata(&path).unwrap().len() <= cap,
447 "the cap is a cap"
448 );
449 }
450
451 /// fails if a record larger than the whole cap is silently dropped. An
452 /// alert too interesting to fit is exactly the one an operator needs;
453 /// leaving the file over its cap for one record is the cheaper wrong.
454 #[test]
455 fn a_bark_bigger_than_the_cap_is_written_anyway() {
456 let dir = tempfile::tempdir().unwrap();
457 let path = dir.path().join("barks.jsonl");
458 let huge = Bark {
459 message: "x".repeat(4096),
460 ..bark_for("web", 0)
461 };
462 append(&path, &huge, 64).unwrap();
463 assert_eq!(read(&path).unwrap().len(), 1);
464 }
465
466 /// fails if one unparseable line refuses the whole history. That line
467 /// is a writer that died mid-append or a record from a future shep, and
468 /// this file is read during an incident — the surviving records are
469 /// what the reader came for.
470 #[test]
471 fn a_line_that_will_not_parse_costs_one_record_and_not_the_file() {
472 let dir = tempfile::tempdir().unwrap();
473 let path = dir.path().join("barks.jsonl");
474 append(&path, &bark_for("web", 1), DEFAULT_MAX_BYTES).unwrap();
475 std::fs::OpenOptions::new()
476 .append(true)
477 .open(&path)
478 .unwrap()
479 .write_all(b"{\"at_ms\": 2, \"rul\n")
480 .unwrap();
481 append(&path, &bark_for("api", 3), DEFAULT_MAX_BYTES).unwrap();
482
483 let barks = read(&path).unwrap();
484 assert_eq!(
485 barks.iter().map(|b| b.subject.as_str()).collect::<Vec<_>>(),
486 ["web", "api"]
487 );
488 }
489
490 /// fails if a missing file is an error. No barks yet is the state every
491 /// machine starts in.
492 #[test]
493 fn no_file_yet_is_no_barks_rather_than_a_failure() {
494 let dir = tempfile::tempdir().unwrap();
495 assert_eq!(read(&dir.path().join("nothing.jsonl")).unwrap(), vec![]);
496 }
497
498 /// Env var naming the ring file the re-executed child should append
499 /// to. Its presence is also what tells the child it is a child.
500 #[cfg(any(unix, windows))]
501 const CHILD_PATH_VAR: &str = "SHEP_BARK_RACE_PATH";
502 /// Env var carrying the child's tag, which it stamps into every
503 /// record's `subject` so the parent can tell the two writers apart.
504 #[cfg(any(unix, windows))]
505 const CHILD_TAG_VAR: &str = "SHEP_BARK_RACE_TAG";
506 /// How many records each of the two writers appends. Large enough that
507 /// the two read-modify-rename sequences overlap many times over; the
508 /// reviewed reproduction of the lost-update bug used this count and
509 /// lost half the records.
510 #[cfg(any(unix, windows))]
511 const RECORDS_PER_WRITER: u64 = 200;
512
513 /// Not a test — the child half of
514 /// [`two_writer_processes_do_not_lose_each_other_s_barks`], which
515 /// re-executes this binary with `--ignored --exact` to reach it. It is
516 /// `#[ignore]`d so a normal run never picks it up, and it asserts
517 /// nothing: its job is to hammer [`append`] from a second OS process,
518 /// and the parent does the judging.
519 #[cfg(any(unix, windows))]
520 #[test]
521 #[ignore = "child process of two_writer_processes_do_not_lose_each_other_s_barks"]
522 fn bark_race_child() {
523 let Ok(path) = std::env::var(CHILD_PATH_VAR) else {
524 panic!("{CHILD_PATH_VAR} unset — this test is only run as a child process");
525 };
526 let tag = std::env::var(CHILD_TAG_VAR).expect("child needs a tag");
527 let path = std::path::PathBuf::from(path);
528
529 for i in 0..RECORDS_PER_WRITER {
530 append(&path, &bark_for(&tag, i), DEFAULT_MAX_BYTES).expect("child append");
531 }
532 }
533
534 /// fails if two writers in two *processes* lose each other's records —
535 /// the whole reason this module lives in shep-core rather than in
536 /// shep-daemon. Two OS processes, not two threads: any in-process
537 /// mutex would serialise threads and prove nothing about the bug,
538 /// which is a read-modify-write across a `rename` with no lock between
539 /// address spaces.
540 ///
541 /// Covers Windows too: this is what keeps the Windows lock honest —
542 /// revert `acquire`'s Windows arm to `Ok(Self {})` and this reddens
543 /// rather than passing quietly.
544 ///
545 /// Without the lock this exposes the race; nothing synchronises the
546 /// two children into overlap, so a lucky serial schedule can still pass
547 /// one run while failing under load.
548 #[cfg(any(unix, windows))]
549 #[test]
550 fn two_writer_processes_do_not_lose_each_other_s_barks() {
551 let dir = tempfile::tempdir().unwrap();
552 let path = dir.path().join("barks.jsonl");
553 let exe = std::env::current_exe().expect("test binary path");
554
555 let children: Vec<_> = ["alpha", "beta"]
556 .iter()
557 .map(|tag| {
558 std::process::Command::new(&exe)
559 .args(["--exact", "--ignored", "barks::tests::bark_race_child"])
560 .env(CHILD_PATH_VAR, &path)
561 .env(CHILD_TAG_VAR, tag)
562 // Piped, not inherited: a passing run should not
563 // interleave two child harnesses' output into this
564 // one's, and a failing child's harness output is
565 // exactly what the assertion below needs to show.
566 .stdout(std::process::Stdio::piped())
567 .spawn()
568 .expect("spawn writer")
569 })
570 .collect();
571
572 for child in children {
573 let out = child.wait_with_output().expect("wait for writer");
574 assert!(
575 out.status.success(),
576 "a writer process failed: {}\n{}",
577 out.status,
578 String::from_utf8_lossy(&out.stdout)
579 );
580 }
581
582 let barks = read(&path).unwrap();
583 for tag in ["alpha", "beta"] {
584 let mut seen: Vec<u64> = barks
585 .iter()
586 .filter(|b| b.subject == tag)
587 .map(|b| b.at_ms)
588 .collect();
589 seen.sort_unstable();
590 let expected: Vec<u64> = (0..RECORDS_PER_WRITER).collect();
591 assert_eq!(
592 seen, expected,
593 "{tag}'s records did not all survive the other writer"
594 );
595 }
596 assert_eq!(
597 barks.len() as u64,
598 2 * RECORDS_PER_WRITER,
599 "the ring holds records nobody wrote"
600 );
601 }
602
603 /// fails if the ring lands wider than owner-only. A `Bark` carries no
604 /// credential today: it holds a rule name, a subject and a message,
605 /// and a [`SinkOutcome`] names a sink by its config key rather than by
606 /// the webhook URL behind it. The mode stays tight anyway, because
607 /// this file is still a record of what the shepherd told an outside
608 /// service, and because a future field that did carry a URL should
609 /// arrive into a file that is already narrow. See
610 /// [`crate::atomic_file::OWNER_ONLY_FILE_MODE`] for the shared value.
611 #[cfg(unix)]
612 #[test]
613 fn append_creates_the_ring_owner_only_on_unix() {
614 use std::os::unix::fs::PermissionsExt;
615 let dir = tempfile::tempdir().unwrap();
616 let path = dir.path().join("barks.jsonl");
617
618 append(&path, &bark_for("web", 0), DEFAULT_MAX_BYTES).unwrap();
619
620 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
621 assert_eq!(
622 mode, 0o600,
623 "barks.jsonl is not the credential file, but stays narrow anyway"
624 );
625 }
626}