use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, RwLock};
use super::state::AppState;
pub(super) const KIND_TASK_LIST: &str = "task_list";
pub(super) const KIND_KV_STORE: &str = "kv_store";
pub(super) const ROLE_CREATED: &str = "created";
pub(super) const ROLE_JOINED: &str = "joined";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(super) struct CrdtSubscriptionEntry {
pub(super) kind: String,
pub(super) id: String,
pub(super) name: String,
pub(super) topic: String,
pub(super) role: String,
#[serde(flatten)]
pub(super) extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(super) struct CrdtSubscriptionManifest {
#[serde(default)]
pub(super) entries: Vec<CrdtSubscriptionEntry>,
#[serde(flatten)]
pub(super) extra: serde_json::Map<String, serde_json::Value>,
}
impl CrdtSubscriptionManifest {
pub(super) fn upsert(&mut self, entry: CrdtSubscriptionEntry) -> bool {
if let Some(existing) = self
.entries
.iter_mut()
.find(|e| e.kind == entry.kind && e.id == entry.id)
{
let mut merged_extra = entry.extra.clone();
for (k, v) in &existing.extra {
merged_extra.entry(k.clone()).or_insert_with(|| v.clone());
}
let merged = CrdtSubscriptionEntry {
kind: existing.kind.clone(),
id: existing.id.clone(),
name: entry.name,
topic: entry.topic,
role: entry.role,
extra: merged_extra,
};
if *existing == merged {
return false;
}
*existing = merged;
} else {
self.entries.push(entry);
}
true
}
#[allow(dead_code)]
pub(super) fn remove(&mut self, kind: &str, id: &str) -> bool {
let before = self.entries.len();
self.entries.retain(|e| !(e.kind == kind && e.id == id));
self.entries.len() != before
}
}
pub(super) async fn read_manifest(path: &Path) -> CrdtSubscriptionManifest {
match tokio::fs::read(path).await {
Ok(bytes) => match serde_json::from_slice::<CrdtSubscriptionManifest>(&bytes) {
Ok(manifest) => manifest,
Err(e) => {
tracing::warn!(
"failed to parse CRDT subscription manifest {} (starting empty): {e}",
path.display()
);
CrdtSubscriptionManifest::default()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!("no CRDT subscription manifest at {}", path.display());
CrdtSubscriptionManifest::default()
}
Err(e) => {
tracing::warn!(
"failed to read CRDT subscription manifest {} (starting empty): {e}",
path.display()
);
CrdtSubscriptionManifest::default()
}
}
}
pub(super) async fn write_manifest(
path: &Path,
manifest: &CrdtSubscriptionManifest,
) -> std::io::Result<()> {
let bytes = serde_json::to_vec_pretty(manifest)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
write_manifest_atomic(path, &bytes).await
}
async fn write_manifest_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let mut temp_os = path.as_os_str().to_owned();
temp_os.push(format!(".{}.tmp", uuid::Uuid::new_v4()));
let temp_path = PathBuf::from(temp_os);
let mut temp = TempFile::new(temp_path.clone());
let result = async {
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)
.await?;
file.write_all(bytes).await?;
file.sync_all().await?;
drop(file);
tokio::fs::rename(&temp_path, path).await?;
if let Err(e) = sync_parent_dir(path) {
tracing::warn!(
"parent-directory fsync failed for {}: refusing to \
acknowledge durability after successful rename ({e})",
path.display()
);
return Err(e);
}
Ok::<(), std::io::Error>(())
}
.await;
match result {
Ok(()) => {
temp.disarm(); Ok(())
}
Err(e) => Err(e), }
}
struct TempFile {
path: PathBuf,
armed: bool,
}
impl TempFile {
fn new(path: PathBuf) -> Self {
Self { path, armed: true }
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for TempFile {
fn drop(&mut self) {
if self.armed {
let _ = std::fs::remove_file(&self.path);
}
}
}
#[cfg(test)]
thread_local! {
static INJECT_DIR_FSYNC_FAILURE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
#[cfg(test)]
pub(super) struct DirFsyncFailureGuard;
#[cfg(test)]
impl DirFsyncFailureGuard {
pub(super) fn enable() -> Self {
INJECT_DIR_FSYNC_FAILURE.with(|c| c.set(true));
DirFsyncFailureGuard
}
}
#[cfg(test)]
impl Drop for DirFsyncFailureGuard {
fn drop(&mut self) {
INJECT_DIR_FSYNC_FAILURE.with(|c| c.set(false));
}
}
#[cfg(unix)]
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
#[cfg(test)]
if INJECT_DIR_FSYNC_FAILURE.with(|c| c.get()) {
return Err(std::io::Error::other("injected dir-fsync failure"));
}
if let Some(parent) = path.parent() {
let dir = std::fs::File::open(parent)?;
dir.sync_all()?;
}
Ok(())
}
#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
Ok(())
}
pub(super) async fn load(state: &AppState) {
let manifest = read_manifest(&state.crdt_subscriptions_path).await;
let n = manifest.entries.len();
*state.crdt_subscriptions.write().await = manifest;
if n > 0 {
tracing::info!(
"loaded {n} persisted CRDT subscriptions from {}",
state.crdt_subscriptions_path.display()
);
}
}
pub(super) async fn record(state: &AppState, entry: CrdtSubscriptionEntry) -> std::io::Result<()> {
persist_recorded(
&state.crdt_subscriptions,
&state.crdt_subscriptions_persistence_lock,
&state.crdt_subscriptions_path,
entry,
)
.await
}
async fn persist_recorded(
manifest: &RwLock<CrdtSubscriptionManifest>,
persistence_lock: &Mutex<()>,
path: &Path,
entry: CrdtSubscriptionEntry,
) -> std::io::Result<()> {
let _guard = persistence_lock.lock().await;
let staged = {
let current = manifest.read().await;
let mut candidate = current.clone();
if !candidate.upsert(entry) {
return Ok(());
}
candidate
};
write_manifest(path, &staged).await?;
*manifest.write().await = staged;
Ok(())
}
pub(super) async fn handle_reservation(state: &AppState, kind: &str, id: &str) -> Arc<Mutex<()>> {
let key = format!("{kind}:{id}");
let mut locks = state.crdt_handle_locks.write().await;
locks
.entry(key)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
fn manifest_policy(
extra: &serde_json::Map<String, serde_json::Value>,
) -> Option<crate::kv::AccessPolicy> {
match extra.get("policy") {
None => Some(crate::kv::AccessPolicy::Signed),
Some(serde_json::Value::String(s)) if s == "signed" => {
Some(crate::kv::AccessPolicy::Signed)
}
Some(serde_json::Value::String(s)) if s == "append_only" => {
Some(crate::kv::AccessPolicy::AppendOnly)
}
Some(_) => None,
}
}
fn parse_owner_hex(hex_str: &str) -> Option<crate::identity::AgentId> {
let bytes = hex::decode(hex_str).ok()?;
if bytes.len() == crate::identity::PEER_ID_LENGTH {
let mut arr = [0u8; crate::identity::PEER_ID_LENGTH];
arr.copy_from_slice(&bytes);
Some(crate::identity::AgentId(arr))
} else {
None
}
}
pub(super) async fn rehydrate(state: Arc<AppState>) {
let entries = state.crdt_subscriptions.read().await.entries.clone();
if entries.is_empty() {
return;
}
let results = futures::future::join_all(
entries
.into_iter()
.map(|entry| rehydrate_one(Arc::clone(&state), entry)),
)
.await;
let (mut restored, mut skipped) = (0usize, 0usize);
for outcome in results {
match outcome {
RehydrateOutcome::Restored => restored += 1,
RehydrateOutcome::Skipped => skipped += 1,
RehydrateOutcome::AlreadyPresent => {}
}
}
tracing::info!(restored, skipped, "CRDT subscription rehydration complete");
}
enum RehydrateOutcome {
Restored,
Skipped,
AlreadyPresent,
}
async fn rehydrate_one(state: Arc<AppState>, entry: CrdtSubscriptionEntry) -> RehydrateOutcome {
let reservation = handle_reservation(&state, &entry.kind, &entry.id).await;
let _guard = reservation.lock().await;
match entry.kind.as_str() {
KIND_TASK_LIST => {
if state.task_lists.read().await.contains_key(&entry.id) {
return RehydrateOutcome::AlreadyPresent; }
let result = match entry.role.as_str() {
ROLE_JOINED => state.agent.join_task_list(&entry.topic).await,
ROLE_CREATED => {
state
.agent
.create_task_list(&entry.name, &entry.topic)
.await
}
other => {
tracing::warn!(
id = %entry.id,
role = other,
"unknown task-list subscription role — skipping"
);
return RehydrateOutcome::Skipped;
}
};
match result {
Ok(handle) => {
super::routes::apply_group_authorization(&state, &entry.id, &handle).await;
state
.task_lists
.write()
.await
.entry(entry.id.clone())
.or_insert(handle);
RehydrateOutcome::Restored
}
Err(e) => {
tracing::warn!(
id = %entry.id,
"failed to rehydrate task list after restart: {e}"
);
RehydrateOutcome::Skipped
}
}
}
KIND_KV_STORE => {
if state.kv_stores.read().await.contains_key(&entry.id) {
return RehydrateOutcome::AlreadyPresent; }
let result = match entry.role.as_str() {
ROLE_JOINED => {
let owner = match entry.extra.get("expected_owner").and_then(|v| v.as_str()) {
Some(hex_str) => match parse_owner_hex(hex_str) {
Some(id) => id,
None => {
tracing::warn!(
id = %entry.id,
"stored expected_owner is malformed; \
skipping rehydration (migration_required)"
);
return RehydrateOutcome::Skipped;
}
},
None => {
tracing::warn!(
id = %entry.id,
"no stored expected_owner for joined store; \
skipping rehydration (migration_required). \
Re-join with an owner anchor to recover."
);
return RehydrateOutcome::Skipped;
}
};
state
.agent
.join_kv_store_persistent(
&entry.topic,
owner,
crate::kv::store::AnchorChannel::Persistence,
&state.kv_store_state_dir,
)
.await
}
ROLE_CREATED => {
let policy = match manifest_policy(&entry.extra) {
Some(policy) => policy,
None => {
tracing::warn!(
id = %entry.id,
"stored kv-store policy is malformed; \
skipping rehydration (migration_required)"
);
return RehydrateOutcome::Skipped;
}
};
state
.agent
.create_kv_store_persistent(
&entry.name,
&entry.topic,
policy,
&state.kv_store_state_dir,
)
.await
}
other => {
tracing::warn!(
id = %entry.id,
role = other,
"unknown kv-store subscription role — skipping"
);
return RehydrateOutcome::Skipped;
}
};
match result {
Ok(handle) => {
state
.kv_stores
.write()
.await
.entry(entry.id.clone())
.or_insert(handle);
RehydrateOutcome::Restored
}
Err(e) => {
tracing::warn!(
id = %entry.id,
"failed to rehydrate kv store after restart: {e}"
);
RehydrateOutcome::Skipped
}
}
}
other => {
tracing::warn!(
id = %entry.id,
kind = other,
"unknown CRDT subscription kind — skipping (kept in manifest)"
);
RehydrateOutcome::Skipped
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_policy_parses_and_fails_closed() {
let mut extra = serde_json::Map::new();
assert_eq!(
manifest_policy(&extra),
Some(crate::kv::AccessPolicy::Signed),
"legacy entry without a policy field is Signed"
);
extra.insert("policy".into(), serde_json::Value::String("signed".into()));
assert_eq!(
manifest_policy(&extra),
Some(crate::kv::AccessPolicy::Signed)
);
extra.insert(
"policy".into(),
serde_json::Value::String("append_only".into()),
);
assert_eq!(
manifest_policy(&extra),
Some(crate::kv::AccessPolicy::AppendOnly)
);
extra.insert(
"policy".into(),
serde_json::Value::String("immutable".into()),
);
assert_eq!(manifest_policy(&extra), None, "garbage fails closed");
extra.insert("policy".into(), serde_json::Value::Bool(true));
assert_eq!(manifest_policy(&extra), None, "non-string fails closed");
}
fn entry(kind: &str, id: &str, role: &str) -> CrdtSubscriptionEntry {
CrdtSubscriptionEntry {
kind: kind.to_string(),
id: id.to_string(),
name: format!("{id}-name"),
topic: id.to_string(),
role: role.to_string(),
extra: serde_json::Map::new(),
}
}
#[tokio::test]
async fn manifest_round_trips_through_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let mut manifest = CrdtSubscriptionManifest::default();
assert!(manifest.upsert(entry(KIND_TASK_LIST, "sprint", ROLE_CREATED)));
assert!(manifest.upsert(entry(KIND_KV_STORE, "party", ROLE_JOINED)));
assert!(!manifest.upsert(entry(KIND_KV_STORE, "party", ROLE_JOINED)));
write_manifest(&path, &manifest)
.await
.expect("write manifest");
let loaded = read_manifest(&path).await;
assert_eq!(loaded, manifest);
let mut loaded = loaded;
assert!(loaded.remove(KIND_KV_STORE, "party"));
assert!(!loaded.remove(KIND_KV_STORE, "party"));
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].id, "sprint");
write_manifest(&path, &loaded)
.await
.expect("write manifest");
assert_eq!(read_manifest(&path).await, loaded);
}
#[test]
fn upsert_replaces_same_kind_and_id() {
let mut manifest = CrdtSubscriptionManifest::default();
manifest.upsert(entry(KIND_KV_STORE, "party", ROLE_JOINED));
manifest.upsert(entry(KIND_KV_STORE, "party", ROLE_CREATED));
assert_eq!(manifest.entries.len(), 1);
assert_eq!(manifest.entries[0].role, ROLE_CREATED);
manifest.upsert(entry(KIND_TASK_LIST, "party", ROLE_CREATED));
assert_eq!(manifest.entries.len(), 2);
}
#[tokio::test]
async fn corrupt_manifest_yields_empty_and_does_not_panic() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
tokio::fs::write(&path, b"{not json at all")
.await
.expect("write corrupt file");
let loaded = read_manifest(&path).await;
assert!(loaded.entries.is_empty());
let missing = dir.path().join("does-not-exist.json");
assert!(read_manifest(&missing).await.entries.is_empty());
}
#[tokio::test]
async fn unknown_fields_are_tolerated_and_preserved() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let future_schema = serde_json::json!({
"schema_version": 2,
"entries": [{
"kind": "kv_store",
"id": "party",
"name": "party",
"topic": "party",
"role": "joined",
"owner": "abcd1234",
"policy": { "kind": "signed" }
}]
});
tokio::fs::write(&path, serde_json::to_vec(&future_schema).expect("json"))
.await
.expect("write");
let loaded = read_manifest(&path).await;
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].extra["owner"], "abcd1234");
assert_eq!(loaded.extra["schema_version"], 2);
write_manifest(&path, &loaded)
.await
.expect("write manifest");
let reloaded = read_manifest(&path).await;
assert_eq!(reloaded, loaded);
}
#[tokio::test]
async fn concurrent_manifest_writes_never_corrupt() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let mut written = Vec::new();
let mut handles = Vec::new();
for i in 0..32u32 {
let mut manifest = CrdtSubscriptionManifest::default();
manifest.upsert(entry(KIND_KV_STORE, &format!("store-{i}"), ROLE_CREATED));
written.push(manifest.clone());
let path = path.clone();
handles.push(tokio::spawn(async move {
let _ = write_manifest(&path, &manifest).await;
}));
}
for handle in handles {
handle.await.expect("writer task panicked");
}
let loaded = read_manifest(&path).await;
assert!(
written.contains(&loaded),
"concurrent writes corrupted the manifest: {loaded:?}"
);
let debris = std::fs::read_dir(dir.path())
.expect("read_dir")
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(debris, 0, "temp-file debris left after concurrent writes");
}
#[cfg(unix)]
#[tokio::test]
async fn failed_write_leaves_last_good_manifest_intact() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let mut good = CrdtSubscriptionManifest::default();
good.upsert(entry(KIND_KV_STORE, "good", ROLE_CREATED));
write_manifest(&path, &good)
.await
.expect("write good manifest");
assert_eq!(read_manifest(&path).await, good);
let mut newer = CrdtSubscriptionManifest::default();
newer.upsert(entry(KIND_KV_STORE, "newer", ROLE_CREATED));
let mut perms = tokio::fs::metadata(dir.path())
.await
.expect("stat dir")
.permissions();
perms.set_mode(0o500);
tokio::fs::set_permissions(dir.path(), perms)
.await
.expect("chmod read-only");
let probe = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dir.path().join("__probe__"))
.await;
let enforced = probe.is_err();
if let Ok(file) = probe {
drop(file);
let _ = tokio::fs::remove_file(dir.path().join("__probe__")).await;
}
if enforced {
assert!(
write_manifest(&path, &newer).await.is_err(),
"blocked write must return Err, not silently succeed"
);
assert_eq!(
read_manifest(&path).await,
good,
"failed write replaced the last good manifest"
);
}
let mut perms = tokio::fs::metadata(dir.path())
.await
.expect("stat dir")
.permissions();
perms.set_mode(0o700);
tokio::fs::set_permissions(dir.path(), perms)
.await
.expect("chmod restore");
}
#[tokio::test]
async fn concurrent_records_cannot_regress_disk_state() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let manifest = Arc::new(RwLock::new(CrdtSubscriptionManifest::default()));
let lock = Arc::new(Mutex::new(()));
const N: u32 = 48;
let mut handles = Vec::new();
for i in 0..N {
let (manifest, lock) = (Arc::clone(&manifest), Arc::clone(&lock));
let path = path.clone();
handles.push(tokio::spawn(async move {
persist_recorded(
&manifest,
&lock,
&path,
entry(KIND_KV_STORE, &format!("store-{i}"), ROLE_CREATED),
)
.await
}));
}
for handle in handles {
handle
.await
.expect("record task panicked")
.expect("persist_recorded should succeed");
}
let loaded = read_manifest(&path).await;
let mut ids: Vec<String> = loaded.entries.iter().map(|e| e.id.clone()).collect();
ids.sort();
let mut expected: Vec<String> = (0..N).map(|i| format!("store-{i}")).collect();
expected.sort();
assert_eq!(
ids, expected,
"lost update: not all concurrent records survived to disk"
);
}
#[cfg(unix)]
#[tokio::test]
async fn write_failure_then_identical_retry() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let manifest = Arc::new(RwLock::new(CrdtSubscriptionManifest::default()));
let lock = Arc::new(Mutex::new(()));
let make_entry = || entry(KIND_KV_STORE, "rollback-store", ROLE_CREATED);
let mut perms = tokio::fs::metadata(dir.path())
.await
.expect("stat dir")
.permissions();
perms.set_mode(0o500);
tokio::fs::set_permissions(dir.path(), perms)
.await
.expect("chmod read-only");
let probe = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dir.path().join("__probe__"))
.await;
let enforced = probe.is_err();
if let Ok(f) = probe {
drop(f);
let _ = tokio::fs::remove_file(dir.path().join("__probe__")).await;
}
if enforced {
let result = persist_recorded(&manifest, &lock, &path, make_entry()).await;
assert!(result.is_err(), "failed durable write must return Err");
assert!(
manifest.read().await.entries.is_empty(),
"in-memory manifest must roll back after a failed durable write"
);
assert!(
read_manifest(&path).await.entries.is_empty(),
"disk must not contain an entry whose write failed"
);
let mut perms = tokio::fs::metadata(dir.path())
.await
.expect("stat dir")
.permissions();
perms.set_mode(0o700);
tokio::fs::set_permissions(dir.path(), perms)
.await
.expect("chmod restore");
persist_recorded(&manifest, &lock, &path, make_entry())
.await
.expect("retry after removing the failure must succeed");
let loaded = read_manifest(&path).await;
assert_eq!(
loaded.entries.len(),
1,
"identical retry after rollback must persist the entry"
);
assert_eq!(loaded.entries[0].id, "rollback-store");
} else {
let mut perms = tokio::fs::metadata(dir.path())
.await
.expect("stat dir")
.permissions();
perms.set_mode(0o700);
tokio::fs::set_permissions(dir.path(), perms)
.await
.expect("chmod restore");
}
}
#[tokio::test]
async fn future_fields_survive_upsert_and_rollback() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let mut future = entry(KIND_KV_STORE, "party", ROLE_CREATED);
future.extra.insert(
"owner_epoch".to_string(),
serde_json::json!({ "epoch": 7, "hash": "deadbeef" }),
);
future
.extra
.insert("policy_version".to_string(), serde_json::json!(3));
let mut manifest = CrdtSubscriptionManifest::default();
assert!(manifest.upsert(future));
write_manifest(&path, &manifest)
.await
.expect("write future manifest");
let mut older = entry(KIND_KV_STORE, "party", ROLE_CREATED);
older
.extra
.insert("expected_owner".to_string(), serde_json::json!("abcd1234"));
assert!(
manifest.upsert(older),
"re-registration providing a new extra field must change the manifest"
);
let merged = &manifest.entries[0];
assert_eq!(
merged.extra["expected_owner"],
serde_json::json!("abcd1234")
);
assert_eq!(merged.extra["policy_version"], serde_json::json!(3));
assert_eq!(merged.extra["owner_epoch"]["epoch"], 7);
write_manifest(&path, &manifest)
.await
.expect("write merged");
let reloaded = read_manifest(&path).await;
assert_eq!(
reloaded.entries[0].extra["expected_owner"],
serde_json::json!("abcd1234")
);
assert_eq!(
reloaded.entries[0].extra["policy_version"],
serde_json::json!(3)
);
assert_eq!(reloaded.entries[0].extra["owner_epoch"]["epoch"], 7);
let mut older_again = entry(KIND_KV_STORE, "party", ROLE_CREATED);
older_again
.extra
.insert("expected_owner".to_string(), serde_json::json!("abcd1234"));
assert!(
!manifest.upsert(older_again),
"identical re-registration must be a no-op"
);
}
#[tokio::test]
async fn record_serializes_through_persistence_lock() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let manifest = Arc::new(RwLock::new(CrdtSubscriptionManifest::default()));
let lock = Arc::new(Mutex::new(()));
let held = lock.lock().await;
let recorder = tokio::spawn({
let (manifest, lock, path) = (Arc::clone(&manifest), Arc::clone(&lock), path.clone());
async move {
persist_recorded(
&manifest,
&lock,
&path,
entry(KIND_KV_STORE, "blocked", ROLE_CREATED),
)
.await
}
});
tokio::task::yield_now().await;
tokio::task::yield_now().await;
assert!(
read_manifest(&path).await.entries.is_empty(),
"recorder must not write while the persistence lock is held"
);
assert!(
!recorder.is_finished(),
"recorder must be blocked on the persistence lock"
);
drop(held);
recorder
.await
.expect("recorder panicked")
.expect("persist_recorded should succeed");
let loaded = read_manifest(&path).await;
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].id, "blocked");
}
#[tokio::test]
async fn cancel_before_rename_leaves_memory_clean_and_retry_writes() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let manifest = Arc::new(RwLock::new(CrdtSubscriptionManifest::default()));
let lock = Arc::new(Mutex::new(()));
let make_entry = || entry(KIND_KV_STORE, "cancel-store", ROLE_CREATED);
let cancelled = tokio::select! {
biased;
res = persist_recorded(&manifest, &lock, &path, make_entry()) => {
assert!(res.is_ok(), "if persist completed it must succeed");
false
}
_ = tokio::task::yield_now() => true,
};
assert!(
cancelled,
"persist_recorded must still be suspended in its disk write when \
cancelled; if it completed, the cancellation hole was not exercised"
);
assert!(
manifest.read().await.entries.is_empty(),
"cancellation mid-write must not leave an un-persisted entry in memory"
);
assert!(
read_manifest(&path).await.entries.is_empty(),
"cancellation mid-write must not leave a partial manifest on disk"
);
let debris = std::fs::read_dir(dir.path())
.expect("read_dir")
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(debris, 0, "cancelled write left temp-file debris");
persist_recorded(&manifest, &lock, &path, make_entry())
.await
.expect("identical retry after cancellation must persist");
let loaded = read_manifest(&path).await;
assert_eq!(loaded.entries.len(), 1, "retry must land the entry on disk");
assert_eq!(loaded.entries[0].id, "cancel-store");
assert_eq!(
manifest.read().await.entries.len(),
1,
"memory and disk must agree after retry"
);
}
#[cfg(unix)]
#[tokio::test]
async fn dir_fsync_failure_refuses_durability_and_leaves_memory_unchanged() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let manifest = Arc::new(RwLock::new(CrdtSubscriptionManifest::default()));
let lock = Arc::new(Mutex::new(()));
let make_entry = || entry(KIND_KV_STORE, "fsync-store", ROLE_CREATED);
let _fail = DirFsyncFailureGuard::enable();
let result = persist_recorded(&manifest, &lock, &path, make_entry()).await;
assert!(
result.is_err(),
"a parent-directory fsync failure must surface as Err, not be \
acknowledged as durable"
);
assert!(
manifest.read().await.entries.is_empty(),
"live memory must not commit a write whose dir-fsync failed"
);
drop(_fail);
persist_recorded(&manifest, &lock, &path, make_entry())
.await
.expect("retry after removing the fsync failure must persist");
let loaded = read_manifest(&path).await;
assert_eq!(loaded.entries.len(), 1, "retry must land the entry on disk");
assert_eq!(loaded.entries[0].id, "fsync-store");
assert_eq!(
manifest.read().await.entries.len(),
1,
"memory and disk must agree after retry"
);
}
#[cfg(unix)]
#[tokio::test]
async fn interleaved_failure_does_not_clobber_sibling_entries() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("crdt-subscriptions.json");
let manifest = Arc::new(RwLock::new(CrdtSubscriptionManifest::default()));
let lock = Arc::new(Mutex::new(()));
let probe = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dir.path().join("__probe__"))
.await;
let enforced = probe.is_err();
if let Ok(f) = probe {
drop(f);
let _ = tokio::fs::remove_file(dir.path().join("__probe__")).await;
}
persist_recorded(
&manifest,
&lock,
&path,
entry(KIND_TASK_LIST, "alpha", ROLE_CREATED),
)
.await
.expect("A (task create) persists");
let owner_hex = "deadbeef".repeat(8);
let join_entry = || {
let mut e = entry(KIND_KV_STORE, "delta", ROLE_JOINED);
e.extra
.insert("expected_owner".to_string(), serde_json::json!(owner_hex));
e
};
if enforced {
let mut perms = tokio::fs::metadata(dir.path())
.await
.expect("stat")
.permissions();
perms.set_mode(0o500);
tokio::fs::set_permissions(dir.path(), perms)
.await
.expect("chmod read-only");
assert!(
persist_recorded(
&manifest,
&lock,
&path,
entry(KIND_KV_STORE, "beta", ROLE_CREATED)
)
.await
.is_err(),
"B fails under a read-only directory"
);
assert!(
persist_recorded(&manifest, &lock, &path, join_entry())
.await
.is_err(),
"D fails under a read-only directory"
);
assert_eq!(
manifest.read().await.entries.len(),
1,
"memory retains only A after failed B/D"
);
let disk_after_fail = read_manifest(&path).await;
assert_eq!(
disk_after_fail.entries.len(),
1,
"disk retains only A after failed B/D"
);
assert_eq!(disk_after_fail.entries[0].id, "alpha");
let mut perms = tokio::fs::metadata(dir.path())
.await
.expect("stat")
.permissions();
perms.set_mode(0o700);
tokio::fs::set_permissions(dir.path(), perms)
.await
.expect("chmod restore");
}
persist_recorded(
&manifest,
&lock,
&path,
entry(KIND_TASK_LIST, "gamma", ROLE_CREATED),
)
.await
.expect("C (task create) persists after restore");
persist_recorded(
&manifest,
&lock,
&path,
entry(KIND_KV_STORE, "beta", ROLE_CREATED),
)
.await
.expect("B retry persists");
persist_recorded(&manifest, &lock, &path, join_entry())
.await
.expect("D retry persists");
let mem = manifest.read().await.clone();
let disk = read_manifest(&path).await;
assert_eq!(
mem, disk,
"memory and disk must agree after interleaved failures"
);
let mut ids: Vec<String> = disk.entries.iter().map(|e| e.id.clone()).collect();
ids.sort();
let expected: Vec<String> = ["alpha", "beta", "delta", "gamma"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(ids, expected);
let joined = disk
.entries
.iter()
.find(|e| e.id == "delta")
.expect("delta present");
assert_eq!(joined.role, ROLE_JOINED);
assert_eq!(joined.extra["expected_owner"], serde_json::json!(owner_hex));
}
}