@@ -25,6 +25,7 @@ use std::{
2525
2626use anyhow:: { Context , Result } ;
2727use iroh:: EndpointAddr ;
28+ use serde:: { Deserialize , Serialize } ;
2829use tokio:: sync:: { Mutex , OwnedMutexGuard , RwLock , mpsc} ;
2930use tokio_util:: sync:: CancellationToken ;
3031
@@ -80,6 +81,16 @@ struct EntryState {
8081 observed : Arc < StdMutex < HashMap < String , ContentHash > > > ,
8182}
8283
84+ /// Atomically persisted authoritative state for one sync entry. `manifest.json`
85+ /// remains a compatibility/inspection projection, but restart recovery reads
86+ /// this combined file so manifest transitions and their observed-disk receipt
87+ /// cannot be torn apart by a crash.
88+ #[ derive( Debug , Serialize , Deserialize ) ]
89+ struct PersistedEntryState {
90+ manifest : Manifest ,
91+ observed : HashMap < String , ContentHash > ,
92+ }
93+
8394/// State captured immediately before an inbound merge. The operation guard
8495/// keeps every engine-driven scan/materialize for this entry out of the middle
8596/// of the wire session; `baseline` is the pre-merge observed-disk receipt that
@@ -152,8 +163,7 @@ impl<T: SyncTransport> SyncEngine<T> {
152163 existing. observed . clone ( ) ,
153164 ) ,
154165 _ => {
155- let node = self . load_node ( cfg) . await ?;
156- let observed = observed_from_disk ( node. manifest ( ) , cfg) ?;
166+ let ( node, observed) = self . load_node_and_observed ( cfg) . await ?;
157167 (
158168 Arc :: new ( Mutex :: new ( node) ) ,
159169 Arc :: new ( Mutex :: new ( ( ) ) ) ,
@@ -176,12 +186,20 @@ impl<T: SyncTransport> SyncEngine<T> {
176186 Ok ( ( ) )
177187 }
178188
179- async fn load_node ( & self , cfg : & SyncEntry ) -> Result < SyncNode > {
189+ async fn load_node_and_observed (
190+ & self ,
191+ cfg : & SyncEntry ,
192+ ) -> Result < ( SyncNode , HashMap < String , ContentHash > ) > {
180193 let mut node = SyncNode :: new ( self . author ) ;
194+ if let Some ( state) = self . read_state ( & cfg. name ) ? {
195+ node. adopt ( & state. manifest ) ;
196+ return Ok ( ( node, state. observed ) ) ;
197+ }
181198 if let Some ( manifest) = self . read_manifest ( & cfg. name ) ? {
182199 node. adopt ( & manifest) ;
183200 }
184- Ok ( node)
201+ let observed = observed_from_disk ( node. manifest ( ) , cfg) ?;
202+ Ok ( ( node, observed) )
185203 }
186204
187205 /// Resolve a sync name to its node (used by the daemon's inbound accept).
@@ -208,11 +226,11 @@ impl<T: SyncTransport> SyncEngine<T> {
208226 return Ok ( None ) ;
209227 } ;
210228 let operation = entry. operation . clone ( ) . lock_owned ( ) . await ;
211- if self . scan_entry ( & entry) . await ? {
212- // Persist the local intent before accepting remote state so a crash
213- // during the wire session cannot lose the tombstone either.
214- self . persist_entry ( & entry ) . await ? ;
215- }
229+ self . scan_entry ( & entry) . await ?;
230+ // Persist the receipt even when the manifest did not change: a legacy
231+ // entry may not have state.json yet, and a crash during this first wire
232+ // session must not lose knowledge of locally observed Present paths.
233+ self . persist_entry ( & entry ) . await ? ;
216234 let baseline = entry. observed . lock ( ) . unwrap ( ) . clone ( ) ;
217235 Ok ( Some ( PreparedInbound {
218236 entry,
@@ -342,7 +360,11 @@ impl<T: SyncTransport> SyncEngine<T> {
342360
343361 async fn persist_entry ( & self , entry : & EntryState ) -> Result < ( ) > {
344362 let manifest = entry. node . lock ( ) . await . manifest ( ) . clone ( ) ;
345- self . write_manifest ( & entry. config . name , & manifest)
363+ let observed = entry. observed . lock ( ) . unwrap ( ) . clone ( ) ;
364+ self . write_state (
365+ & entry. config . name ,
366+ & PersistedEntryState { manifest, observed } ,
367+ )
346368 }
347369
348370 fn manifest_path ( & self , name : & str ) -> PathBuf {
@@ -353,6 +375,26 @@ impl<T: SyncTransport> SyncEngine<T> {
353375 . join ( "manifest.json" )
354376 }
355377
378+ fn state_path ( & self , name : & str ) -> PathBuf {
379+ self . home
380+ . root ( )
381+ . join ( "sync" )
382+ . join ( sanitize_name ( name) )
383+ . join ( "state.json" )
384+ }
385+
386+ fn read_state ( & self , name : & str ) -> Result < Option < PersistedEntryState > > {
387+ let path = self . state_path ( name) ;
388+ if !path. exists ( ) {
389+ return Ok ( None ) ;
390+ }
391+ let raw = std:: fs:: read_to_string ( & path)
392+ . with_context ( || format ! ( "failed to read {}" , path. display( ) ) ) ?;
393+ let state: PersistedEntryState = serde_json:: from_str ( & raw )
394+ . with_context ( || format ! ( "failed to parse {}" , path. display( ) ) ) ?;
395+ Ok ( Some ( state) )
396+ }
397+
356398 fn read_manifest ( & self , name : & str ) -> Result < Option < Manifest > > {
357399 let path = self . manifest_path ( name) ;
358400 if !path. exists ( ) {
@@ -374,6 +416,20 @@ impl<T: SyncTransport> SyncEngine<T> {
374416 write_atomic ( & path, raw. as_bytes ( ) )
375417 }
376418
419+ fn write_state ( & self , name : & str , state : & PersistedEntryState ) -> Result < ( ) > {
420+ let path = self . state_path ( name) ;
421+ if let Some ( parent) = path. parent ( ) {
422+ std:: fs:: create_dir_all ( parent) ?;
423+ }
424+ let raw = serde_json:: to_string_pretty ( state) ?;
425+ // The combined state is authoritative and lands atomically first.
426+ write_atomic ( & path, raw. as_bytes ( ) ) ?;
427+ // Keep the established manifest path current for operators and older
428+ // Fabric binaries. A crash between these writes still recovers from the
429+ // already-committed combined state above.
430+ self . write_manifest ( name, & state. manifest )
431+ }
432+
377433 /// Start watching every configured entry's folder and syncing on change,
378434 /// then run until the cancellation token fires. Idempotent per entry.
379435 pub async fn run ( self : & Arc < Self > ) -> Result < ( ) > {
@@ -1307,6 +1363,78 @@ mod tests {
13071363 ) ) ;
13081364 }
13091365
1366+ #[ tokio:: test]
1367+ async fn inactive_entry_archive_uses_durable_observed_receipt_on_reload ( ) {
1368+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1369+ let root = dir. path ( ) . join ( "resources" ) ;
1370+ std:: fs:: create_dir_all ( root. join ( "inbox" ) ) . unwrap ( ) ;
1371+ std:: fs:: write ( root. join ( "inbox/archived.md" ) , b"archive offline" ) . unwrap ( ) ;
1372+ write_bus_sync ( dir. path ( ) , & root) ;
1373+
1374+ let engine = SyncEngine :: new (
1375+ FabricHome :: new ( dir. path ( ) ) ,
1376+ Author ( [ 1 ; 32 ] ) ,
1377+ Arc :: new ( LoopbackTransport :: default ( ) ) ,
1378+ CancellationToken :: new ( ) ,
1379+ )
1380+ . await
1381+ . unwrap ( ) ;
1382+ engine. sync_once ( "bus" ) . await . unwrap ( ) ;
1383+
1384+ // Disable the entry while its inbox Present is durably observed, then
1385+ // perform the archive while no watcher/entry can see the rename.
1386+ std:: fs:: write ( dir. path ( ) . join ( "syncs.toml" ) , "" ) . unwrap ( ) ;
1387+ engine. load_from_config ( ) . await . unwrap ( ) ;
1388+ assert ! ( engine. node_for( "bus" ) . await . is_none( ) ) ;
1389+ std:: fs:: create_dir_all ( root. join ( "archive" ) ) . unwrap ( ) ;
1390+ std:: fs:: rename (
1391+ root. join ( "inbox/archived.md" ) ,
1392+ root. join ( "archive/archived.md" ) ,
1393+ )
1394+ . unwrap ( ) ;
1395+
1396+ // Cross an actual daemon/engine restart boundary while the entry is
1397+ // still disabled. Remove the compatibility projection as well, proving
1398+ // that the authoritative combined state is the only recovery source.
1399+ let state_path = engine. state_path ( "bus" ) ;
1400+ let manifest_path = engine. manifest_path ( "bus" ) ;
1401+ assert ! ( state_path. exists( ) ) ;
1402+ drop ( engine) ;
1403+ std:: fs:: remove_file ( & manifest_path) . unwrap ( ) ;
1404+
1405+ let engine = SyncEngine :: new (
1406+ FabricHome :: new ( dir. path ( ) ) ,
1407+ Author ( [ 1 ; 32 ] ) ,
1408+ Arc :: new ( LoopbackTransport :: default ( ) ) ,
1409+ CancellationToken :: new ( ) ,
1410+ )
1411+ . await
1412+ . unwrap ( ) ;
1413+ assert ! ( engine. node_for( "bus" ) . await . is_none( ) ) ;
1414+ assert ! ( !manifest_path. exists( ) ) ;
1415+
1416+ write_bus_sync ( dir. path ( ) , & root) ;
1417+ engine. load_from_config ( ) . await . unwrap ( ) ;
1418+ engine. sync_once ( "bus" ) . await . unwrap ( ) ;
1419+
1420+ let node = engine. node_for ( "bus" ) . await . unwrap ( ) ;
1421+ let node = node. lock ( ) . await ;
1422+ assert ! ( matches!(
1423+ node. manifest( ) . get( "inbox/archived.md" ) ,
1424+ Some ( Entry :: Tombstone ( _) )
1425+ ) ) ;
1426+ assert ! ( matches!(
1427+ node. manifest( ) . get( "archive/archived.md" ) ,
1428+ Some ( Entry :: Present ( _) )
1429+ ) ) ;
1430+ drop ( node) ;
1431+ assert ! ( !root. join( "inbox/archived.md" ) . exists( ) ) ;
1432+ assert_eq ! (
1433+ std:: fs:: read( root. join( "archive/archived.md" ) ) . unwrap( ) ,
1434+ b"archive offline"
1435+ ) ;
1436+ }
1437+
13101438 #[ tokio:: test]
13111439 async fn local_edit_after_post_merge_scan_is_not_overwritten ( ) {
13121440 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
0 commit comments