char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somadd `avril:kv` module78f4748

main
20.3 KiB574 linesraw
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 {
34    path: Arc<PathBuf>,
35    // Serializes operations within this process so our own isolates queue
36    // here instead of spinning on the cross-process file lock.
37    gate: Arc<Mutex<()>>,
38}
39
40impl Persistent {
41    pub fn new(cache_dir: impl AsRef<Path>, config: &Path, entry: &str) -> Self {
42        let mut hasher = Sha256::new();
43        hasher.update(b"avril-webstorage");
44        field(&mut hasher, b"v1");
45        field(&mut hasher, config.to_string_lossy().as_bytes());
46        field(&mut hasher, entry.as_bytes());
47        let digest = format!("{:x}", hasher.finalize());
48        Self {
49            path: Arc::new(
50                cache_dir
51                    .as_ref()
52                    .join("webstorage")
53                    .join("v1")
54                    .join(format!("{digest}.redb")),
55            ),
56            gate: Arc::new(Mutex::new(())),
57        }
58    }
59
60    pub 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.
67    fn wait(&self, delay: &mut Duration, started: Instant, noticed: &mut bool) {
68        if !*noticed && started.elapsed() >= Duration::from_secs(2) {
69            eprintln!(
70                "localStorage: waiting for {} (held by another process)",
71                self.path.display()
72            );
73            *noticed = true;
74        }
75        let 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
83    fn 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.
90    fn run<R>(&self, mut op: impl FnMut() -> io::Result<R>) -> io::Result<R> {
91        let _gate = lock(&self.gate);
92        let mut delay = Duration::from_millis(1);
93        let started = Instant::now();
94        let mut noticed = false;
95        loop {
96            match op() {
97                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
98                    self.wait(&mut delay, started, &mut noticed)
99                }
100                result => return result,
101            }
102        }
103    }
104
105    fn missing(&self) -> bool {
106        !self.path.exists() || self.is_empty_file()
107    }
108
109    fn store(&self, create: bool) -> io::Result<Option<Store>> {
110        match Store::open(&self.path, Access::Shared, create) {
111            Err(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).
119    fn read_store(&self) -> io::Result<Option<Store>> {
120        if self.missing() {
121            return Ok(None);
122        }
123        Store::open_read_only_repairing(&self.path).map(Some)
124    }
125
126    fn get(&self, key: &str) -> io::Result<Option<String>> {
127        self.run(|| {
128            let Some(store) = self.read_store()? else {
129                return Ok(None);
130            };
131            let value = store.read_tx(|tx| tx.get(KEYSPACE, key.as_bytes()))?;
132            match value {
133                Some((bytes, _)) => String::from_utf8(bytes).map(Some).map_err(io::Error::other),
134                None => Ok(None),
135            }
136        })
137    }
138
139    fn keys(&self) -> io::Result<Vec<String>> {
140        self.run(|| {
141            let Some(store) = self.read_store()? else {
142                return Ok(Vec::new());
143            };
144            let 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
152    fn key_at(&self, index: u64) -> io::Result<Option<String>> {
153        self.run(|| {
154            let Some(store) = self.read_store()? else {
155                return Ok(None);
156            };
157            let entries = store.read_tx(|tx| tx.entries(KEYSPACE))?;
158            match entries
159                .into_iter()
160                .map(|(key, _, _)| key)
161                .nth(usize::try_from(index).unwrap_or(usize::MAX))
162            {
163                Some(key) => String::from_utf8(key).map(Some).map_err(io::Error::other),
164                None => Ok(None),
165            }
166        })
167    }
168
169    fn length(&self) -> io::Result<u64> {
170        self.run(|| {
171            let Some(store) = self.read_store()? else {
172                return Ok(0);
173            };
174            store.read_tx(|tx| tx.count(KEYSPACE))
175        })
176    }
177
178    fn set(&self, key: &str, value: &str) -> io::Result<bool> {
179        let size = (key.len() + value.len()) as u64;
180        if size > QUOTA_BYTES {
181            return Ok(false);
182        }
183        self.run(|| {
184            if let Some(parent) = self.path.parent() {
185                std::fs::create_dir_all(parent)?;
186            }
187            let store = Store::open(&self.path, Access::Shared, true)?;
188            store.write_tx(|tx| {
189                let used = tx.get_meta(USED_KEY)?.unwrap_or(0);
190                let 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.
195                let 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                };
203                let total = used
204                    .checked_sub(replaced)
205                    .and_then(|total| total.checked_add(size))
206                    .ok_or_else(|| io::Error::other("Storage size overflow"))?;
207                if total <= QUOTA_BYTES {
208                    tx.put(KEYSPACE, key.as_bytes(), value.as_bytes())?;
209                    tx.put_meta(USED_KEY, total)?;
210                    Ok(Decision::Commit(true))
211                } else {
212                    Ok(Decision::Abort(false))
213                }
214            })
215        })
216    }
217
218    fn remove(&self, key: &str) -> io::Result<()> {
219        self.run(|| {
220            let Some(store) = self.store(false)? else {
221                return Ok(());
222            };
223            store.write_tx(|tx| match tx.get(KEYSPACE, key.as_bytes())? {
224                Some((old, _)) => {
225                    tx.delete(KEYSPACE, key.as_bytes())?;
226                    let used = tx.get_meta(USED_KEY)?.unwrap_or(0);
227                    tx.put_meta(
228                        USED_KEY,
229                        used.saturating_sub((key.len() + old.len()) as u64),
230                    )?;
231                    Ok(Decision::Commit(()))
232                }
233                None => Ok(Decision::Abort(())),
234            })
235        })
236    }
237
238    fn clear(&self) -> io::Result<()> {
239        self.run(|| {
240            let Some(store) = self.store(false)? else {
241                return Ok(());
242            };
243            store.write_tx(|tx| {
244                tx.clear(KEYSPACE)?;
245                tx.put_meta(USED_KEY, 0)?;
246                Ok(Decision::Commit(()))
247            })
248        })
249    }
250}
251
252#[derive(Default)]
253struct Session {
254    entries: BTreeMap<String, String>,
255    used: u64,
256}
257
258struct Stores {
259    local: Persistent,
260    session: Mutex<Session>,
261}
262
263impl Stores {
264    fn context<T>(&self, result: io::Result<T>) -> io::Result<T> {
265        result.map_err(|error| {
266            let message = error.to_string();
267            let 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.
270            let 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}",
280                self.local.path().display()
281            ))
282        })
283    }
284
285    fn get(&self, persistent: bool, key: &str) -> io::Result<Option<String>> {
286        if persistent {
287            self.context(self.local.get(key))
288        } else {
289            Ok(lock(&self.session).entries.get(key).cloned())
290        }
291    }
292
293    fn keys(&self, persistent: bool) -> io::Result<Vec<String>> {
294        if persistent {
295            self.context(self.local.keys())
296        } else {
297            Ok(lock(&self.session).entries.keys().cloned().collect())
298        }
299    }
300
301    fn key_at(&self, persistent: bool, index: u64) -> io::Result<Option<String>> {
302        if persistent {
303            self.context(self.local.key_at(index))
304        } else {
305            Ok(lock(&self.session)
306                .entries
307                .keys()
308                .nth(usize::try_from(index).unwrap_or(usize::MAX))
309                .cloned())
310        }
311    }
312
313    fn length(&self, persistent: bool) -> io::Result<u64> {
314        if persistent {
315            self.context(self.local.length())
316        } else {
317            Ok(lock(&self.session).entries.len() as u64)
318        }
319    }
320
321    fn set(&self, persistent: bool, key: &str, value: &str) -> io::Result<bool> {
322        if persistent {
323            return self.context(self.local.set(key, value));
324        }
325        let mut session = lock(&self.session);
326        let size = (key.len() + value.len()) as u64;
327        let replaced = session
328            .entries
329            .get(key)
330            .map_or(0, |old| (key.len() + old.len()) as u64);
331        let Some(total) = session
332            .used
333            .checked_sub(replaced)
334            .and_then(|total| total.checked_add(size))
335        else {
336            return Err(io::Error::other("Storage size overflow"));
337        };
338        if total > QUOTA_BYTES {
339            return Ok(false);
340        }
341        session.entries.insert(key.to_owned(), value.to_owned());
342        session.used = total;
343        Ok(true)
344    }
345
346    fn remove(&self, persistent: bool, key: &str) -> io::Result<()> {
347        if persistent {
348            self.context(self.local.remove(key))
349        } else {
350            let mut session = lock(&self.session);
351            if let Some(old) = session.entries.remove(key) {
352                session.used = session.used.saturating_sub((key.len() + old.len()) as u64);
353            }
354            Ok(())
355        }
356    }
357
358    fn clear(&self, persistent: bool) -> io::Result<()> {
359        if persistent {
360            self.context(self.local.clear())
361        } else {
362            let mut session = lock(&self.session);
363            session.entries.clear();
364            session.used = 0;
365            Ok(())
366        }
367    }
368}
369
370pub fn init(local: Persistent) -> Extension {
371    let stores = Arc::new(Stores {
372        local,
373        session: Mutex::new(Session::default()),
374    });
375    Extension {
376        name: "ext:webstorage",
377        dependencies: &["ext:web"],
378        ops: vec![
379            Op::new("op_webstorage_get", {
380                let stores = stores.clone();
381                move |(persistent, key): (bool, String)| stores.get(persistent, &key)
382            }),
383            Op::new("op_webstorage_keys", {
384                let stores = stores.clone();
385                move |persistent: bool| stores.keys(persistent)
386            }),
387            Op::new("op_webstorage_key", {
388                let stores = stores.clone();
389                move |(persistent, index): (bool, u64)| stores.key_at(persistent, index)
390            }),
391            Op::new("op_webstorage_length", {
392                let stores = stores.clone();
393                move |persistent: bool| stores.length(persistent)
394            }),
395            Op::new("op_webstorage_set", {
396                let stores = stores.clone();
397                move |(persistent, key, value): (bool, String, String)| {
398                    stores.set(persistent, &key, &value)
399                }
400            }),
401            Op::new("op_webstorage_remove", {
402                let stores = stores.clone();
403                move |(persistent, key): (bool, String)| stores.remove(persistent, &key)
404            }),
405            Op::new("op_webstorage_clear", {
406                let stores = stores.clone();
407                move |persistent: bool| stores.clear(persistent)
408            }),
409        ],
410        modules: &[
411            (
412                "ext:webstorage/main.js",
413                include_str!(concat!(env!("OUT_DIR"), "/main.js")),
414            ),
415            (
416                "ext:webstorage/storage.js",
417                include_str!(concat!(env!("OUT_DIR"), "/storage.js")),
418            ),
419        ],
420        entry_point: Some("ext:webstorage/main.js"),
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use redb::{Database, TableDefinition};
428
429    fn stores(cache: &Path) -> Stores {
430        Stores {
431            local: Persistent::new(cache, Path::new("/project/avril.conf"), "/project/main.js"),
432            session: Mutex::new(Session::default()),
433        }
434    }
435
436    #[test]
437    fn construction_and_reads_create_nothing() {
438        let dir = tempfile::tempdir().unwrap();
439        let stores = stores(dir.path());
440        assert!(!stores.local.path().exists());
441        assert_eq!(stores.get(true, "missing").unwrap(), None);
442        assert_eq!(stores.keys(true).unwrap(), Vec::<String>::new());
443        stores.remove(true, "missing").unwrap();
444        stores.clear(true).unwrap();
445        assert!(!stores.local.path().exists());
446    }
447
448    #[test]
449    fn local_persists_across_instances_but_session_does_not() {
450        let dir = tempfile::tempdir().unwrap();
451        let first = stores(dir.path());
452        assert!(first.set(true, "key", "value").unwrap());
453        assert!(first.set(false, "key", "session").unwrap());
454        let second = stores(dir.path());
455        assert_eq!(second.get(true, "key").unwrap(), Some("value".to_owned()));
456        assert_eq!(second.get(false, "key").unwrap(), None);
457        assert_ne!(
458            Persistent::new(
459                dir.path(),
460                Path::new("/other/avril.conf"),
461                "/project/main.js"
462            )
463            .path(),
464            second.local.path()
465        );
466        assert_ne!(
467            Persistent::new(
468                dir.path(),
469                Path::new("/project/avril.conf"),
470                "/other/main.js"
471            )
472            .path(),
473            second.local.path()
474        );
475    }
476
477    #[test]
478    fn quota_counts_utf8_and_credits_replacements() {
479        let dir = tempfile::tempdir().unwrap();
480        let stores = stores(dir.path());
481        // "é" is two UTF-8 bytes; key + value exactly fills the quota.
482        let value = "x".repeat((QUOTA_BYTES - 2) as usize);
483        assert!(stores.set(true, "é", &value).unwrap());
484        assert!(!stores.set(true, "a", "").unwrap());
485        assert_eq!(stores.get(true, "a").unwrap(), None);
486        // Shrinking an existing entry succeeds and frees space.
487        assert!(stores.set(true, "é", "").unwrap());
488        assert!(stores.set(true, "a", "b").unwrap());
489        stores.remove(true, "é").unwrap();
490        stores.clear(true).unwrap();
491        assert_eq!(stores.keys(true).unwrap(), Vec::<String>::new());
492    }
493
494    #[test]
495    fn operations_wait_for_another_holders_handle() {
496        let dir = tempfile::tempdir().unwrap();
497        let stores = stores(dir.path());
498        assert!(stores.set(true, "key", "before").unwrap());
499        std::fs::create_dir_all(stores.local.path().parent().unwrap()).unwrap();
500        let holder = Database::create(stores.local.path()).unwrap();
501        let path = stores.local.path().to_owned();
502        let cache = dir.path().to_owned();
503        let (attempted, attempted_rx) = std::sync::mpsc::channel::<()>();
504        let writer = std::thread::spawn(move || {
505            let stores = Stores {
506                local: Persistent::new(cache, Path::new("/project/avril.conf"), "/project/main.js"),
507                session: Mutex::new(Session::default()),
508            };
509            assert_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));
515        assert!(!writer.is_finished(), "writer did not wait for the holder");
516        drop(holder);
517        assert!(writer.join().unwrap().unwrap());
518        assert_eq!(stores.get(true, "key").unwrap(), Some("after".to_owned()));
519    }
520
521    #[test]
522    fn 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.
525        if let Ok(cache) = std::env::var("AVRIL_WEBSTORAGE_UNCLEAN_CACHE") {
526            let stores = stores(Path::new(&cache));
527            std::fs::create_dir_all(stores.local.path().parent().unwrap()).unwrap();
528            let 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        }
537        let dir = tempfile::tempdir().unwrap();
538        let 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();
544        assert!(status.success());
545        let stores = stores(dir.path());
546        assert_eq!(stores.get(true, "key").unwrap(), Some("unclean".to_owned()));
547    }
548
549    #[test]
550    fn legacy_format_files_read_as_empty() {
551        let dir = tempfile::tempdir().unwrap();
552        let stores = stores(dir.path());
553        std::fs::create_dir_all(stores.local.path().parent().unwrap()).unwrap();
554        {
555            let database = Database::create(stores.local.path()).unwrap();
556            let transaction = database.begin_write().unwrap();
557            {
558                let 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...
566        assert_eq!(stores.get(true, "old").unwrap(), None);
567        assert_eq!(stores.length(true).unwrap(), 0);
568        assert_eq!(stores.keys(true).unwrap(), Vec::<String>::new());
569        // ...and the database is fully usable from the new format onward.
570        assert!(stores.set(true, "new", "value").unwrap());
571        assert_eq!(stores.get(true, "new").unwrap(), Some("value".to_owned()));
572        assert_eq!(stores.get(true, "old").unwrap(), None);
573    }
574}