Files
velzieandClaude Opus 5.5 c8521c2b60 eject: the persist store snapshots soon after the program saves
A reload does not wait for the snapshot pagehide starts, and the 30 s
timer had often not come round: a save made seconds before a reload was
gone after it. The global kernel now counts the changes the program
finishes under the persist root (k_fs_watch, k_fs_saves): a close of a
handle that wrote, created or truncated, a rename into or out of it, an
unlink. The persist worker reads the count every 250 ms and snapshots
1 s after the last change, or 5 s after the first unsnapshotted one if
they keep coming. The timer, visibilitychange and pagehide stay as the
backstop. With no root watched nothing is counted; with nothing saved
the cost is one load per poll.

fsync is not counted: in the page it reaches the global kernel through
no call of its own, and the close or rename after it is.

tests/eject/persist-save: a probe saves through a symlink into /save,
by a temporary file fsynced and renamed over the save and by stdio over
it, keeps running, and is reloaded 3 s later, three visits for each way.
Without the count the second visit finds no save.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_8b3490cccb4848b495dde1c5cdcacba0
2026-10-24 11:49:26 -04:00

81 lines
3.1 KiB
JavaScript

// A guest directory that outlives the page (an ejected program's saves and
// settings), kept in IndexedDB.
//
// - `restore(db, root)`, before the program starts: the files stored last
// time, to lay over the bundle's.
// - `persist(db, root, spec)`, once it runs: a Worker (persist-worker.js)
// snapshots `root` from the global kernel's filesystem about a second
// after the program finishes a change under it (a close after a write, a
// rename, an unlink), and also every 30 s, when the page is hidden and
// when it is left, storing what changed. A reload does not wait for the
// last of these, so the first is what keeps a save.
//
// What a snapshot sees is whatever the program has written so far; a save
// half-written at the instant of a snapshot is stored half-written, and the
// next snapshot fixes it (IDBFS's syncfs has the same window).
import { openDb, readAll } from './persist-db.js';
import { logOn } from '@gex/log.mjs';
export async function restore(dbName, root) {
try {
const db = await openDb(dbName);
const all = await readAll(db);
db.close();
const prefix = `${root.replace(/\/+$/, '')}/`;
return all.filter(([p]) => p.startsWith(prefix));
} catch (e) {
console.warn(`[persist] nothing restored: ${e?.message ?? e}`);
return [];
}
}
export function persist(dbName, root, spec, { every = 30000, onSync = null } = {}) {
if (!spec?.module || !spec?.memory) {
console.warn('[persist] no global kernel (no SharedArrayBuffer): saves will not outlive the page');
return null;
}
const worker = new Worker(new URL('./persist-worker.js', import.meta.url), { type: 'module', name: 'persist' });
let seq = 0;
let busy = false;
const waiters = new Map();
worker.onmessage = (e) => {
const m = e.data;
if (m.error && !('synced' in m)) console.warn(`[persist] ${m.error}`);
if (m.auto) {
if ((m.wrote || m.removed) && logOn('debug')) console.log(`[persist] after a save: stored ${m.wrote} file(s) (${m.bytes} bytes), removed ${m.removed}; ${m.files} under ${root}`);
onSync?.(m);
}
if ('synced' in m) {
busy = false;
if (m.error) console.warn(`[persist] snapshot failed: ${m.error}`);
else if ((m.wrote || m.removed) && logOn('debug')) console.log(`[persist] stored ${m.wrote} file(s) (${m.bytes} bytes), removed ${m.removed}; ${m.files} under ${root}`);
onSync?.(m);
waiters.get(m.synced)?.(m);
waiters.delete(m.synced);
}
};
worker.postMessage({ spec: { module: spec.module, memory: spec.memory }, root, db: dbName });
const sync = () => {
// One snapshot at a time; a request while one runs is left to it.
if (busy) return Promise.resolve(null);
busy = true;
const id = ++seq;
const done = new Promise((resolve) => waiters.set(id, resolve));
worker.postMessage({ sync: id });
return done;
};
const timer = setInterval(sync, every);
addEventListener('pagehide', sync);
document.addEventListener('visibilitychange', () => {
if (document.hidden) sync();
});
return {
sync,
stop() {
clearInterval(timer);
worker.terminate();
},
};
}