char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
78f4748
main
1use std:: collections:: BTreeMap ; 2use std:: io; 3use std:: path::{ Path , PathBuf }; 4use std:: sync::{ Arc , Mutex , MutexGuard }; 5use std:: time::{ Duration , Instant , SystemTime , UNIX_EPOCH }; 6 7use avril_core::{ Extension , Op }; 8use avril_kv::{ Access , Decision , Store }; 9use sha2::{ Digest , Sha256 }; 10 11pub const QUOTA_BYTES : u64 =10 * 1024 * 1024 ; 12 13// Single keyspace holding raw UTF-8 bytes. Files in the pre-unification 14// layout (old "kv"/"meta" tables) are not read; first write creates the 15// keyspace as usual. 16const KEYSPACE : & str ="webstorage" ; 17const USED_KEY : & str ="webstorage/bytes_used" ; 18 19fn lock < T >( mutex : & Mutex < T >) ->MutexGuard < ' _ , T > { 20 mutex. lock (). unwrap_or_else ( |error| error. into_inner ()) 21} 22 23fn field ( hasher : & mut Sha256 , value : & [ u8 ]) { 24 hasher. update (( value. len () as u64 ). to_be_bytes ()); 25 hasher. update ( value); 26} 27 28/// Persistent localStorage for one (config, entry point) namespace. 29/// 30/// Constructing this performs no filesystem work; the database file is only 31/// opened, inside a single operation, when storage is actually read or written. 32# [ derive ( Clone )] 33pub struct Persistent { 34path : Arc < PathBuf >, 35// Serializes operations within this process so our own isolates queue 36// here instead of spinning on the cross-process file lock. 37gate : Arc < Mutex <()>>, 38} 39 40impl Persistent { 41pub fn new ( cache_dir : impl AsRef < Path >, config : & Path , entry : & str ) ->Self { 42let mut hasher =Sha256 :: new (); 43 hasher. update ( b"avril-webstorage" ); 44field ( & mut hasher, b"v1" ); 45field ( & mut hasher, config. to_string_lossy (). as_bytes ()); 46field ( & mut hasher, entry. as_bytes ()); 47let digest =format! ( "{:x}" , hasher. finalize ()); 48Self { 49path : Arc :: new ( 50 cache_dir 51. as_ref () 52. join ( "webstorage" ) 53. join ( "v1" ) 54. join ( format! ( "{digest}.redb" )), 55), 56gate : Arc :: new ( Mutex :: new (())), 57} 58} 59 60pub fn path ( & self ) ->& Path { 61& self . path 62} 63 64// Another process holds the file for the duration of one of its 65// operations. The lock is OS-held, so a crashed holder releases it and 66// waiting cannot deadlock on stale state. 67fn wait ( & self , delay : & mut Duration , started : Instant , noticed : & mut bool ) { 68if !* noticed && started. elapsed () >=Duration :: from_secs ( 2 ) { 69eprintln! ( 70"localStorage: waiting for {} (held by another process)" , 71self . path . display () 72); 73* noticed =true ; 74} 75let jitter =SystemTime :: now () 76. duration_since ( UNIX_EPOCH ) 77. map ( |now| now. subsec_nanos () as u64 %( delay. as_millis () as u64 +1 )) 78. unwrap_or ( 0 ); 79 std:: thread:: sleep ( * delay +Duration :: from_millis ( jitter)); 80* delay =( * delay* 2 ). min ( Duration :: from_millis ( 50 )); 81} 82 83fn is_empty_file ( & self ) ->bool { 84 std:: fs:: metadata ( &* self . path ). is_ok_and ( |metadata| metadata. len () ==0 ) 85} 86 87// Runs one whole operation, waiting out cross-process file locks instead 88// of failing on them. Every operation is idempotent, so retrying after 89// a wait is always safe. 90fn run < R >( & self , mut op : impl FnMut () -> io:: Result < R >) -> io:: Result < R > { 91let _gate =lock ( & self . gate ); 92let mut delay =Duration :: from_millis ( 1 ); 93let started =Instant :: now (); 94let mut noticed =false ; 95loop { 96match op () { 97Err ( error) if error. kind () == io:: ErrorKind :: AlreadyExists =>{ 98self . wait ( & mut delay, started, & mut noticed) 99} 100 result =>return result, 101} 102} 103} 104 105fn missing ( & self ) ->bool { 106 !self . path . exists () ||self . is_empty_file () 107} 108 109fn store ( & self , create : bool ) -> io:: Result < Option < Store >> { 110match Store :: open ( & self . path , Access :: Shared , create) { 111Err ( error) if error. kind () == io:: ErrorKind :: NotFound =>Ok ( None ), 112 store => store. map ( Some ), 113} 114} 115 116// Reads go through a read-only handle so merely reading storage never 117// modifies the database file (a writable open could repair or touch 118// file metadata even when it changes no entries). 119fn read_store ( & self ) -> io:: Result < Option < Store >> { 120if self . missing () { 121return Ok ( None ); 122} 123Store :: open_read_only_repairing ( & self . path ). map ( Some ) 124} 125 126fn get ( & self , key : & str ) -> io:: Result < Option < String >> { 127self . run ( ||{ 128let Some ( store) =self . read_store () ?else { 129return Ok ( None ); 130}; 131let value = store. read_tx ( |tx| tx. get ( KEYSPACE , key. as_bytes ())) ?; 132match value{ 133Some (( bytes, _)) =>String :: from_utf8 ( bytes). map ( Some ). map_err ( io:: Error :: other), 134None =>Ok ( None ), 135} 136}) 137} 138 139fn keys ( & self ) -> io:: Result < Vec < String >> { 140self . run ( ||{ 141let Some ( store) =self . read_store () ?else { 142return Ok ( Vec :: new ()); 143}; 144let entries = store. read_tx ( |tx| tx. entries ( KEYSPACE )) ?; 145 entries 146. into_iter () 147. map ( |( key, _, _) |String :: from_utf8 ( key). map_err ( io:: Error :: other)) 148. collect () 149}) 150} 151 152fn key_at ( & self , index : u64 ) -> io:: Result < Option < String >> { 153self . run ( ||{ 154let Some ( store) =self . read_store () ?else { 155return Ok ( None ); 156}; 157let entries = store. read_tx ( |tx| tx. entries ( KEYSPACE )) ?; 158match entries 159. into_iter () 160. map ( |( key, _, _) | key) 161. nth ( usize:: try_from ( index). unwrap_or ( usize:: MAX )) 162{ 163Some ( key) =>String :: from_utf8 ( key). map ( Some ). map_err ( io:: Error :: other), 164None =>Ok ( None ), 165} 166}) 167} 168 169fn length ( & self ) -> io:: Result < u64 > { 170self . run ( ||{ 171let Some ( store) =self . read_store () ?else { 172return Ok ( 0 ); 173}; 174 store. read_tx ( |tx| tx. count ( KEYSPACE )) 175}) 176} 177 178fn set ( & self , key : & str , value : & str ) -> io:: Result < bool > { 179let size =( key. len () + value. len ()) as u64 ; 180if size >QUOTA_BYTES { 181return Ok ( false ); 182} 183self . run ( ||{ 184if let Some ( parent) =self . path . parent () { 185 std:: fs:: create_dir_all ( parent) ?; 186} 187let store =Store :: open ( & self . path , Access :: Shared , true ) ?; 188 store. write_tx ( |tx|{ 189let used = tx. get_meta ( USED_KEY ) ?. unwrap_or ( 0 ); 190let replaced = tx 191. get ( KEYSPACE , key. as_bytes ()) ? 192. map_or ( 0 , |( old, _) |( key. len () + old. len ()) as u64 ); 193// A drifted counter must not lock writes out forever: rebuild 194// it from the table itself rather than failing. 195let used =if used < replaced{ 196 tx. entries ( KEYSPACE ) ? 197. iter () 198. map ( |( key, value, _) |( key. len () + value. len ()) as u64 ) 199. sum () 200} else { 201 used 202}; 203let total = used 204. checked_sub ( replaced) 205. and_then ( |total| total. checked_add ( size)) 206. ok_or_else ( || io:: Error :: other ( "Storage size overflow" )) ?; 207if total <=QUOTA_BYTES { 208 tx. put ( KEYSPACE , key. as_bytes (), value. as_bytes ()) ?; 209 tx. put_meta ( USED_KEY , total) ?; 210Ok ( Decision :: Commit ( true )) 211} else { 212Ok ( Decision :: Abort ( false )) 213} 214}) 215}) 216} 217 218fn remove ( & self , key : & str ) -> io:: Result <()> { 219self . run ( ||{ 220let Some ( store) =self . store ( false ) ?else { 221return Ok (()); 222}; 223 store. write_tx ( |tx|match tx. get ( KEYSPACE , key. as_bytes ()) ?{ 224Some (( old, _)) =>{ 225 tx. delete ( KEYSPACE , key. as_bytes ()) ?; 226let used = tx. get_meta ( USED_KEY ) ?. unwrap_or ( 0 ); 227 tx. put_meta ( 228USED_KEY , 229 used. saturating_sub (( key. len () + old. len ()) as u64 ), 230) ?; 231Ok ( Decision :: Commit (())) 232} 233None =>Ok ( Decision :: Abort (())), 234}) 235}) 236} 237 238fn clear ( & self ) -> io:: Result <()> { 239self . run ( ||{ 240let Some ( store) =self . store ( false ) ?else { 241return Ok (()); 242}; 243 store. write_tx ( |tx|{ 244 tx. clear ( KEYSPACE ) ?; 245 tx. put_meta ( USED_KEY , 0 ) ?; 246Ok ( Decision :: Commit (())) 247}) 248}) 249} 250} 251 252# [ derive ( Default )] 253struct Session { 254entries : BTreeMap < String , String >, 255used : u64 , 256} 257 258struct Stores { 259local : Persistent , 260session : Mutex < Session >, 261} 262 263impl Stores { 264fn context < T >( & self , result : io:: Result < T >) -> io:: Result < T > { 265 result. map_err ( |error|{ 266let message = error. to_string (); 267let lower = message. to_lowercase (); 268// The delete-to-reset hint only helps when the file itself is 269// unusable, not for e.g. permission errors. 270let hint =if [ "magic number" , "corrupt" , "file format" , "upgrade" ] 271. iter () 272. any ( |marker| lower. contains ( marker)) 273{ 274"; delete the file to reset storage" 275} else { 276"" 277}; 278 io:: Error :: other ( format! ( 279"localStorage ({}): {message}{hint}" , 280self . local . path (). display () 281)) 282}) 283} 284 285fn get ( & self , persistent : bool , key : & str ) -> io:: Result < Option < String >> { 286if persistent{ 287self . context ( self . local . get ( key)) 288} else { 289Ok ( lock ( & self . session ). entries . get ( key). cloned ()) 290} 291} 292 293fn keys ( & self , persistent : bool ) -> io:: Result < Vec < String >> { 294if persistent{ 295self . context ( self . local . keys ()) 296} else { 297Ok ( lock ( & self . session ). entries . keys (). cloned (). collect ()) 298} 299} 300 301fn key_at ( & self , persistent : bool , index : u64 ) -> io:: Result < Option < String >> { 302if persistent{ 303self . context ( self . local . key_at ( index)) 304} else { 305Ok ( lock ( & self . session ) 306. entries 307. keys () 308. nth ( usize:: try_from ( index). unwrap_or ( usize:: MAX )) 309. cloned ()) 310} 311} 312 313fn length ( & self , persistent : bool ) -> io:: Result < u64 > { 314if persistent{ 315self . context ( self . local . length ()) 316} else { 317Ok ( lock ( & self . session ). entries . len () as u64 ) 318} 319} 320 321fn set ( & self , persistent : bool , key : & str , value : & str ) -> io:: Result < bool > { 322if persistent{ 323return self . context ( self . local . set ( key, value)); 324} 325let mut session =lock ( & self . session ); 326let size =( key. len () + value. len ()) as u64 ; 327let replaced = session 328. entries 329. get ( key) 330. map_or ( 0 , |old|( key. len () + old. len ()) as u64 ); 331let Some ( total) = session 332. used 333. checked_sub ( replaced) 334. and_then ( |total| total. checked_add ( size)) 335else { 336return Err ( io:: Error :: other ( "Storage size overflow" )); 337}; 338if total >QUOTA_BYTES { 339return Ok ( false ); 340} 341 session. entries . insert ( key. to_owned (), value. to_owned ()); 342 session. used = total; 343Ok ( true ) 344} 345 346fn remove ( & self , persistent : bool , key : & str ) -> io:: Result <()> { 347if persistent{ 348self . context ( self . local . remove ( key)) 349} else { 350let mut session =lock ( & self . session ); 351if let Some ( old) = session. entries . remove ( key) { 352 session. used = session. used . saturating_sub (( key. len () + old. len ()) as u64 ); 353} 354Ok (()) 355} 356} 357 358fn clear ( & self , persistent : bool ) -> io:: Result <()> { 359if persistent{ 360self . context ( self . local . clear ()) 361} else { 362let mut session =lock ( & self . session ); 363 session. entries . clear (); 364 session. used =0 ; 365Ok (()) 366} 367} 368} 369 370pub fn init ( local : Persistent ) ->Extension { 371let stores =Arc :: new ( Stores { 372 local, 373session : Mutex :: new ( Session :: default ()), 374}); 375Extension { 376name : "ext:webstorage" , 377dependencies : & [ "ext:web" ], 378ops : vec! [ 379Op :: new ( "op_webstorage_get" , { 380let stores = stores. clone (); 381move |( persistent, key): ( bool , String ) | stores. get ( persistent, & key) 382}), 383Op :: new ( "op_webstorage_keys" , { 384let stores = stores. clone (); 385move |persistent : bool | stores. keys ( persistent) 386}), 387Op :: new ( "op_webstorage_key" , { 388let stores = stores. clone (); 389move |( persistent, index): ( bool , u64 ) | stores. key_at ( persistent, index) 390}), 391Op :: new ( "op_webstorage_length" , { 392let stores = stores. clone (); 393move |persistent : bool | stores. length ( persistent) 394}), 395Op :: new ( "op_webstorage_set" , { 396let stores = stores. clone (); 397move |( persistent, key, value): ( bool , String , String ) |{ 398 stores. set ( persistent, & key, & value) 399} 400}), 401Op :: new ( "op_webstorage_remove" , { 402let stores = stores. clone (); 403move |( persistent, key): ( bool , String ) | stores. remove ( persistent, & key) 404}), 405Op :: new ( "op_webstorage_clear" , { 406let stores = stores. clone (); 407move |persistent : bool | stores. clear ( persistent) 408}), 409], 410modules : & [ 411( 412"ext:webstorage/main.js" , 413include_str! ( concat! ( env! ( "OUT_DIR" ), "/main.js" )), 414), 415( 416"ext:webstorage/storage.js" , 417include_str! ( concat! ( env! ( "OUT_DIR" ), "/storage.js" )), 418), 419], 420entry_point : Some ( "ext:webstorage/main.js" ), 421} 422} 423 424# [ cfg ( test )] 425mod tests{ 426use super :: * ; 427use redb::{ Database , TableDefinition }; 428 429fn stores ( cache : & Path ) ->Stores { 430Stores { 431local : Persistent :: new ( cache, Path :: new ( "/project/avril.conf" ), "/project/main.js" ), 432session : Mutex :: new ( Session :: default ()), 433} 434} 435 436# [ test ] 437fn construction_and_reads_create_nothing () { 438let dir = tempfile:: tempdir (). unwrap (); 439let stores =stores ( dir. path ()); 440assert! ( !stores. local . path (). exists ()); 441assert_eq! ( stores. get ( true , "missing" ). unwrap (), None ); 442assert_eq! ( stores. keys ( true ). unwrap (), Vec ::< String >:: new ()); 443 stores. remove ( true , "missing" ). unwrap (); 444 stores. clear ( true ). unwrap (); 445assert! ( !stores. local . path (). exists ()); 446} 447 448# [ test ] 449fn local_persists_across_instances_but_session_does_not () { 450let dir = tempfile:: tempdir (). unwrap (); 451let first =stores ( dir. path ()); 452assert! ( first. set ( true , "key" , "value" ). unwrap ()); 453assert! ( first. set ( false , "key" , "session" ). unwrap ()); 454let second =stores ( dir. path ()); 455assert_eq! ( second. get ( true , "key" ). unwrap (), Some ( "value" . to_owned ())); 456assert_eq! ( second. get ( false , "key" ). unwrap (), None ); 457assert_ne! ( 458Persistent :: new ( 459 dir. path (), 460Path :: new ( "/other/avril.conf" ), 461"/project/main.js" 462) 463. path (), 464 second. local . path () 465); 466assert_ne! ( 467Persistent :: new ( 468 dir. path (), 469Path :: new ( "/project/avril.conf" ), 470"/other/main.js" 471) 472. path (), 473 second. local . path () 474); 475} 476 477# [ test ] 478fn quota_counts_utf8_and_credits_replacements () { 479let dir = tempfile:: tempdir (). unwrap (); 480let stores =stores ( dir. path ()); 481// "é" is two UTF-8 bytes; key + value exactly fills the quota. 482let value ="x" . repeat (( QUOTA_BYTES -2 ) as usize ); 483assert! ( stores. set ( true , "é" , & value). unwrap ()); 484assert! ( !stores. set ( true , "a" , "" ). unwrap ()); 485assert_eq! ( stores. get ( true , "a" ). unwrap (), None ); 486// Shrinking an existing entry succeeds and frees space. 487assert! ( stores. set ( true , "é" , "" ). unwrap ()); 488assert! ( stores. set ( true , "a" , "b" ). unwrap ()); 489 stores. remove ( true , "é" ). unwrap (); 490 stores. clear ( true ). unwrap (); 491assert_eq! ( stores. keys ( true ). unwrap (), Vec ::< String >:: new ()); 492} 493 494# [ test ] 495fn operations_wait_for_another_holders_handle () { 496let dir = tempfile:: tempdir (). unwrap (); 497let stores =stores ( dir. path ()); 498assert! ( stores. set ( true , "key" , "before" ). unwrap ()); 499 std:: fs:: create_dir_all ( stores. local . path (). parent (). unwrap ()). unwrap (); 500let holder =Database :: create ( stores. local . path ()). unwrap (); 501let path = stores. local . path (). to_owned (); 502let cache = dir. path (). to_owned (); 503let ( attempted, attempted_rx) = std:: sync:: mpsc:: channel ::<()>(); 504let writer = std:: thread:: spawn ( move ||{ 505let stores =Stores { 506local : Persistent :: new ( cache, Path :: new ( "/project/avril.conf" ), "/project/main.js" ), 507session : Mutex :: new ( Session :: default ()), 508}; 509assert_eq! ( stores. local . path (), path); 510 attempted. send (()). unwrap (); 511 stores. set ( true , "key" , "after" ) 512}); 513 attempted_rx. recv (). unwrap (); 514 std:: thread:: sleep ( Duration :: from_millis ( 100 )); 515assert! ( !writer. is_finished (), "writer did not wait for the holder" ); 516drop ( holder); 517assert! ( writer. join (). unwrap (). unwrap ()); 518assert_eq! ( stores. get ( true , "key" ). unwrap (), Some ( "after" . to_owned ())); 519} 520 521# [ test ] 522fn reads_repair_a_database_left_unclean () { 523// Child mode: commit a value through a held-open Store, then exit 524// without dropping it, leaving the file marked as needing repair. 525if let Ok ( cache) = std:: env:: var ( "AVRIL_WEBSTORAGE_UNCLEAN_CACHE" ) { 526let stores =stores ( Path :: new ( & cache)); 527 std:: fs:: create_dir_all ( stores. local . path (). parent (). unwrap ()). unwrap (); 528let store =Store :: open ( stores. local . path (), Access :: Exclusive , true ). unwrap (); 529 store 530. write_tx ( |tx|{ 531 tx. put ( "webstorage" , b"key" , b"unclean" ) 532. map ( Decision :: Commit ) 533}) 534. unwrap (); 535 std:: process:: exit ( 0 ); 536} 537let dir = tempfile:: tempdir (). unwrap (); 538let status = std:: process:: Command :: new ( std:: env:: current_exe (). unwrap ()) 539. args ([ "--exact" , "tests::reads_repair_a_database_left_unclean" ]) 540. env ( "AVRIL_WEBSTORAGE_UNCLEAN_CACHE" , dir. path ()) 541. stdout ( std:: process:: Stdio :: null ()) 542. status () 543. unwrap (); 544assert! ( status. success ()); 545let stores =stores ( dir. path ()); 546assert_eq! ( stores. get ( true , "key" ). unwrap (), Some ( "unclean" . to_owned ())); 547} 548 549# [ test ] 550fn legacy_format_files_read_as_empty () { 551let dir = tempfile:: tempdir (). unwrap (); 552let stores =stores ( dir. path ()); 553 std:: fs:: create_dir_all ( stores. local . path (). parent (). unwrap ()). unwrap (); 554{ 555let database =Database :: create ( stores. local . path ()). unwrap (); 556let transaction = database. begin_write (). unwrap (); 557{ 558let mut table = transaction 559. open_table ( TableDefinition ::< & str , & str >:: new ( "kv" )) 560. unwrap (); 561 table. insert ( "old" , "data" ). unwrap (); 562} 563 transaction. commit (). unwrap (); 564} 565// The pre-unification layout is not read: the file looks empty... 566assert_eq! ( stores. get ( true , "old" ). unwrap (), None ); 567assert_eq! ( stores. length ( true ). unwrap (), 0 ); 568assert_eq! ( stores. keys ( true ). unwrap (), Vec ::< String >:: new ()); 569// ...and the database is fully usable from the new format onward. 570assert! ( stores. set ( true , "new" , "value" ). unwrap ()); 571assert_eq! ( stores. get ( true , "new" ). unwrap (), Some ( "value" . to_owned ())); 572assert_eq! ( stores. get ( true , "old" ). unwrap (), None ); 573} 574}