Skip to content

Commit 4629aeb

Browse files
authored
fix(event): fall back to a byte-copy receipt when capability linkat is unsupported (#309)
archive_validated_file hardlinks the validated predecessor through /proc/self/fd/N (Linux) or /dev/fd/N (elsewhere) with AT_SYMLINK_FOLLOW. Linux procfs permits materializing that link; macOS fdescfs answers with EPERM, so publication failed on every Darwin host and cargo test -p st2 --lib could not pass there since #300. Classify EPERM/ENOSYS/EOPNOTSUPP from the capability linkat as platform-unsupported and install the receipt as a staged, fsynced, rename_noreplace byte copy instead; every other error stays a hard failure. The caller's readback proves whichever receipt won carries the validated bytes, so supersession semantics are unchanged. The tradeoff is inode identity: a crash between copy and conditional unlink leaves the retained inbox entry in place until revalidation. A debug-only TEST_FORCE_ARCHIVE_RECEIPT_COPY switch exercises the fallback on every platform; the new ding supersession test asserts receipt bytes match and no staging files leak into the archive. Fixes #308
1 parent 9b1a3b4 commit 4629aeb

2 files changed

Lines changed: 161 additions & 2 deletions

File tree

src/ding/mod.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3292,6 +3292,87 @@ Enter to select · ↑/↓ to navigate · Esc to cancel";
32923292
);
32933293
}
32943294

3295+
/// Platforms that cannot hardlink through the open-file descriptor path (macOS fdescfs
3296+
/// rejects linkat(AT_SYMLINK_FOLLOW) on /dev/fd/N with EPERM) fall back to a byte-copy
3297+
/// archive receipt. The differentiated supersession semantics must hold unchanged on that
3298+
/// path, the receipt must carry exactly the validated bytes, and no staging file may leak
3299+
/// into the archive.
3300+
#[test]
3301+
#[cfg(debug_assertions)]
3302+
fn archive_copy_fallback_preserves_supersede_ownership_without_staging_leftovers() {
3303+
crate::event::TEST_FORCE_ARCHIVE_RECEIPT_COPY.store(true, Ordering::Relaxed);
3304+
struct ResetGuard;
3305+
impl Drop for ResetGuard {
3306+
fn drop(&mut self) {
3307+
crate::event::TEST_FORCE_ARCHIVE_RECEIPT_COPY.store(false, Ordering::Relaxed);
3308+
}
3309+
}
3310+
let _guard = ResetGuard;
3311+
3312+
let (catalog, inbox) = event_catalog();
3313+
let root = catalog.path();
3314+
let archive = crate::message::archive_dir(&root.join("hetz").join("worker"));
3315+
3316+
let failure_filename = emit_ci(root, "failure", true);
3317+
let mut seen = HashSet::new();
3318+
let mut pending: VecDeque<PendingNotice> = new_arrivals(&inbox, &mut seen)
3319+
.into_iter()
3320+
.map(PendingNotice::message)
3321+
.collect();
3322+
assert_eq!(pending.len(), 1);
3323+
let failure_bytes =
3324+
std::fs::read(inbox.join(&failure_filename)).expect("staged event bytes");
3325+
let failure_text = pending[0].text(
3326+
DingContext {
3327+
catalog_root: root,
3328+
this_host: "hetz",
3329+
recipient: "hetz.worker",
3330+
},
3331+
&mut None,
3332+
);
3333+
3334+
let poker = OwnershipPoker {
3335+
pokes: Mutex::new(Vec::new()),
3336+
retries: Mutex::new(Vec::new()),
3337+
poke_outcomes: Mutex::new(VecDeque::from([PokeOutcome::Staged])),
3338+
retry_outcomes: Mutex::new(VecDeque::from([PokeOutcome::Staged])),
3339+
};
3340+
flush_in(root, &mut pending, &poker);
3341+
3342+
emit_ci(root, "success", true);
3343+
pending.extend(
3344+
new_arrivals(&inbox, &mut seen)
3345+
.into_iter()
3346+
.map(PendingNotice::message),
3347+
);
3348+
prune_archived_pending(&inbox, &mut pending);
3349+
flush_in(root, &mut pending, &poker);
3350+
3351+
assert_eq!(pending.len(), 2, "later FIFO work remains blocked");
3352+
assert_eq!(
3353+
poker.pokes.lock().unwrap().as_slice(),
3354+
[failure_text.as_str()],
3355+
"the successor is never pasted on top of a retained payload"
3356+
);
3357+
assert!(!inbox.join(&failure_filename).exists(), "head was archived");
3358+
let receipt = std::fs::read(archive.join(&failure_filename))
3359+
.expect("byte-copy archive receipt exists");
3360+
assert_eq!(
3361+
receipt, failure_bytes,
3362+
"the copy receipt carries exactly the validated bytes"
3363+
);
3364+
let staging_leftovers: Vec<_> = std::fs::read_dir(&archive)
3365+
.unwrap()
3366+
.filter_map(Result::ok)
3367+
.map(|entry| entry.file_name().to_string_lossy().into_owned())
3368+
.filter(|name| name.starts_with(".st2-archive-"))
3369+
.collect();
3370+
assert!(
3371+
staging_leftovers.is_empty(),
3372+
"no staging files survive in the archive: {staging_leftovers:?}"
3373+
);
3374+
}
3375+
32953376
#[test]
32963377
fn archived_not_retained_releases_fifo_without_repasting_owned_notice() {
32973378
let agent = tempfile::tempdir().unwrap();

src/event.rs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::os::unix::fs::DirBuilderExt as _;
1111
use std::os::unix::fs::MetadataExt as _;
1212
use std::os::unix::fs::OpenOptionsExt as _;
1313
use std::path::Path;
14-
use std::sync::atomic::{AtomicU64, Ordering};
14+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
1515

1616
use anyhow::Context as _;
1717
use serde::{Deserialize, Serialize};
@@ -672,7 +672,14 @@ fn archive_validated_file(
672672
};
673673
if result < 0 {
674674
let error = std::io::Error::last_os_error();
675-
if error.kind() != std::io::ErrorKind::AlreadyExists {
675+
if error.kind() == std::io::ErrorKind::AlreadyExists {
676+
// A receipt for this predecessor already exists; the readback below proves it
677+
// carries exactly the validated bytes.
678+
} else if capability_link_unsupported(&error) {
679+
// The platform cannot hardlink through the open-file descriptor path at all;
680+
// degrade to a byte-copy receipt instead of failing publication.
681+
write_archive_receipt_copy(file, &archive_dir, archive, filename)?;
682+
} else {
676683
return Err(error).context("archive the validated predecessor capability");
677684
}
678685
}
@@ -690,6 +697,77 @@ fn archive_validated_file(
690697
Ok(())
691698
}
692699

700+
/// Whether linkat through the open-file capability path is unsupported by the platform rather
701+
/// than a real failure. macOS fdescfs answers linkat(AT_SYMLINK_FOLLOW) on /dev/fd/N with
702+
/// EPERM; ENOSYS/EOPNOTSUPP cover kernels lacking the syscall or its symlink-follow semantics.
703+
/// Everything else stays a hard error so genuine failures (permissions, cross-device, ...)
704+
/// surface instead of being silently degraded to a copy.
705+
fn capability_link_unsupported(error: &std::io::Error) -> bool {
706+
#[cfg(debug_assertions)]
707+
if TEST_FORCE_ARCHIVE_RECEIPT_COPY.load(Ordering::Relaxed) {
708+
return true;
709+
}
710+
matches!(
711+
error.raw_os_error(),
712+
Some(libc::EPERM) | Some(libc::ENOSYS) | Some(libc::EOPNOTSUPP)
713+
)
714+
}
715+
716+
/// Debug-only switch letting tests exercise the byte-copy fallback on platforms where the real
717+
/// capability linkat would succeed. Not a supported configuration knob. Flipping this mid-run
718+
/// is safe: the fallback receipt is verified against the validated bytes exactly like the
719+
/// hardlink path, and the same-inode unlink treats a copy receipt as "archived" regardless.
720+
#[cfg(debug_assertions)]
721+
pub(crate) static TEST_FORCE_ARCHIVE_RECEIPT_COPY: AtomicBool = AtomicBool::new(false);
722+
723+
/// Materialize the archive receipt as a byte copy of the validated file, for platforms that
724+
/// cannot hardlink through the open-file descriptor path.
725+
///
726+
/// The staged temp keeps a concurrent archiver from observing a partial receipt, and
727+
/// rename_noreplace turns an install race into a no-op: whichever receipt wins, the caller's
728+
/// readback proves it carries exactly the validated bytes. The tradeoff against the hardlink
729+
/// fast path is inode identity -- a crash between the copy and the conditional unlink leaves
730+
/// the retained inbox entry in place until revalidation, which the same-inode checks read as
731+
/// "still present", never as data loss.
732+
fn write_archive_receipt_copy(
733+
file: &File,
734+
archive_dir: &File,
735+
archive: &Path,
736+
filename: &str,
737+
) -> anyhow::Result<()> {
738+
let mut source = file.try_clone()?;
739+
source.rewind()?;
740+
let mut bytes = Vec::new();
741+
source.read_to_end(&mut bytes)?;
742+
drop(source);
743+
744+
let staged = archive.join(format!(
745+
".st2-archive-{}-{}",
746+
std::process::id(),
747+
TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
748+
));
749+
let mut staged_file = OpenOptions::new()
750+
.read(true)
751+
.write(true)
752+
.create_new(true)
753+
.mode(0o600)
754+
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
755+
.open(&staged)?;
756+
staged_file.write_all(&bytes)?;
757+
staged_file.sync_all()?;
758+
drop(staged_file);
759+
760+
let target = archive.join(filename);
761+
if let Err(error) = crate::catalog_transaction::rename_noreplace(&staged, &target) {
762+
fs::remove_file(&staged).context("remove staging copy after failed receipt install")?;
763+
if error.kind() != std::io::ErrorKind::AlreadyExists {
764+
return Err(error).context("install archived predecessor receipt");
765+
}
766+
}
767+
archive_dir.sync_all()?;
768+
Ok(())
769+
}
770+
693771
fn conditional_unlink_same_inode(
694772
inbox_file: &File,
695773
expected_file: &File,

0 commit comments

Comments
 (0)