@@ -394,6 +394,140 @@ async fn open_preserves_corrupt_store_and_dirty_sentinel_for_offline_repair()
394394 Ok ( ( ) )
395395}
396396
397+ #[ tokio:: test]
398+ async fn dirty_open_checks_integrity_before_writable_migration ( )
399+ -> std:: result:: Result < ( ) , Box < dyn std:: error:: Error > > {
400+ let dir = TempDir :: new ( ) ?;
401+ let project_root = dir. path ( ) . join ( "repo" ) ;
402+ std:: fs:: create_dir_all ( & project_root) ?;
403+ let open_options = TraceDecayOpenOptions {
404+ profile_root : Some ( dir. path ( ) . join ( "profile" ) ) ,
405+ global_db_path : Some ( dir. path ( ) . join ( "global.db" ) ) ,
406+ } ;
407+
408+ let ts = TraceDecay :: init_with_options ( & project_root, open_options. clone ( ) ) . await ?;
409+ let layout = ts. store_layout ( ) . clone ( ) ;
410+ ts. db ( )
411+ . conn ( )
412+ . execute_batch ( "PRAGMA user_version = 17" )
413+ . await ?;
414+ ts. checkpoint ( ) . await ?;
415+ ts. close ( ) ;
416+
417+ let mut file = std:: fs:: OpenOptions :: new ( )
418+ . read ( true )
419+ . write ( true )
420+ . open ( & layout. graph_db_path ) ?;
421+ let offset = std:: cmp:: min ( file. metadata ( ) ?. len ( ) / 2 , 8192 ) ;
422+ file. seek ( std:: io:: SeekFrom :: Start ( offset) ) ?;
423+ file. write_all ( & [ 0xFF ; 256 ] ) ?;
424+ file. sync_all ( ) ?;
425+ drop ( file) ;
426+ std:: fs:: write ( & layout. dirty_path , "pid=99999\n version=test" ) ?;
427+
428+ let before = std:: fs:: read ( & layout. graph_db_path ) ?;
429+ let result = TraceDecay :: open_with_options ( & project_root, open_options) . await ;
430+ assert ! ( result. is_err( ) , "damaged dirty store must require recovery" ) ;
431+ assert_eq ! (
432+ std:: fs:: read( & layout. graph_db_path) ?,
433+ before,
434+ "integrity failure must be detected before writable migration"
435+ ) ;
436+ assert ! ( layout. dirty_path. exists( ) ) ;
437+ Ok ( ( ) )
438+ }
439+
440+ #[ tokio:: test]
441+ async fn dirty_open_does_not_race_an_active_sync_lock ( )
442+ -> std:: result:: Result < ( ) , Box < dyn std:: error:: Error > > {
443+ let dir = TempDir :: new ( ) ?;
444+ let project_root = dir. path ( ) . join ( "repo" ) ;
445+ std:: fs:: create_dir_all ( & project_root) ?;
446+ let open_options = TraceDecayOpenOptions {
447+ profile_root : Some ( dir. path ( ) . join ( "profile" ) ) ,
448+ global_db_path : Some ( dir. path ( ) . join ( "global.db" ) ) ,
449+ } ;
450+
451+ let ts = TraceDecay :: init_with_options ( & project_root, open_options. clone ( ) ) . await ?;
452+ let layout = ts. store_layout ( ) . clone ( ) ;
453+ ts. close ( ) ;
454+ let active_lock = layout. graph_db_path . with_file_name ( format ! (
455+ "{}.sync.lock" ,
456+ layout. graph_db_path. file_name( ) . unwrap( ) . to_string_lossy( )
457+ ) ) ;
458+ std:: fs:: write ( & active_lock, std:: process:: id ( ) . to_string ( ) ) ?;
459+ std:: fs:: write ( & layout. dirty_path , "pid=99999\n version=test" ) ?;
460+ let before = std:: fs:: read ( & layout. graph_db_path ) ?;
461+
462+ let error = match TraceDecay :: open_with_options ( & project_root, open_options) . await {
463+ Ok ( _) => panic ! ( "active writer lock must block recovery" ) ,
464+ Err ( error) => error,
465+ } ;
466+ assert ! (
467+ error
468+ . to_string( )
469+ . contains( "another sync is already in progress" )
470+ ) ;
471+ assert_eq ! ( std:: fs:: read( & layout. graph_db_path) ?, before) ;
472+ assert ! ( layout. dirty_path. exists( ) ) ;
473+ Ok ( ( ) )
474+ }
475+
476+ #[ tokio:: test]
477+ async fn dirty_open_recovers_committed_rows_before_clearing_sentinel ( )
478+ -> std:: result:: Result < ( ) , Box < dyn std:: error:: Error > > {
479+ let dir = TempDir :: new ( ) ?;
480+ let project_root = dir. path ( ) . join ( "repo" ) ;
481+ std:: fs:: create_dir_all ( & project_root) ?;
482+ let open_options = TraceDecayOpenOptions {
483+ profile_root : Some ( dir. path ( ) . join ( "profile" ) ) ,
484+ global_db_path : Some ( dir. path ( ) . join ( "global.db" ) ) ,
485+ } ;
486+
487+ let ts = TraceDecay :: init_with_options ( & project_root, open_options. clone ( ) ) . await ?;
488+ let layout = ts. store_layout ( ) . clone ( ) ;
489+ ts. db ( )
490+ . conn ( )
491+ . execute_batch ( "PRAGMA wal_autocheckpoint = 0" )
492+ . await ?;
493+ let mut journal_rows = ts. db ( ) . conn ( ) . query ( "PRAGMA journal_mode" , ( ) ) . await ?;
494+ let journal_mode = journal_rows
495+ . next ( )
496+ . await ?
497+ . expect ( "journal mode row" )
498+ . get :: < String > ( 0 ) ?;
499+ drop ( journal_rows) ;
500+ let node = sample_node ( "wal-recovery-node" , "wal_recovery_node" ) ;
501+ ts. db ( ) . insert_nodes ( std:: slice:: from_ref ( & node) ) . await ?;
502+ if journal_mode. eq_ignore_ascii_case ( "wal" ) {
503+ let mut wal_path = layout. graph_db_path . as_os_str ( ) . to_os_string ( ) ;
504+ wal_path. push ( "-wal" ) ;
505+ assert ! (
506+ std:: fs:: metadata( std:: path:: PathBuf :: from( wal_path) ) ?. len( ) > 0 ,
507+ "disabled autocheckpoint must retain committed WAL frames"
508+ ) ;
509+ } else {
510+ assert ! (
511+ matches!(
512+ journal_mode. to_ascii_lowercase( ) . as_str( ) ,
513+ "delete" | "memory"
514+ ) ,
515+ "production recovery fixture must use a platform-safe non-WAL journal"
516+ ) ;
517+ }
518+ std:: fs:: write ( & layout. dirty_path , "pid=99999\n version=test" ) ?;
519+
520+ let recovered = TraceDecay :: open_with_options ( & project_root, open_options) . await ?;
521+ assert ! ( recovered. get_node( & node. id) . await ?. is_some( ) ) ;
522+ assert ! (
523+ !layout. dirty_path. exists( ) ,
524+ "sentinel clears only after WAL-aware quick_check succeeds"
525+ ) ;
526+ recovered. close ( ) ;
527+ ts. close ( ) ;
528+ Ok ( ( ) )
529+ }
530+
397531#[ tokio:: test]
398532async fn corrupt_db_detected_and_repaired_on_reopen ( ) {
399533 let dir = TempDir :: new ( ) . unwrap ( ) ;
0 commit comments