library: the other window on the same origin draws the scan instead of running a second one

An installed app and a browser tab are one origin, one catalogue and one roll.
Without a word between them the second window opens, finds nothing in flight,
and reads the same frames again — two readers of one folder, two RAW decoders at
256MB apiece, for a progress the first window already has.

The window holding the reading now announces it on every frame and on a
heartbeat, and answers a window that has just opened and asks. A window that
does not hold the reading takes it as its own: the same progress line, the
folder marked as being read, the same STOP — which it relays rather than
redirects — and the same jump. What it does not do is read the disk.

A reading that is through leaves the catalogue behind, and a window watching it
has nothing left to be told, so the screen reads the catalogue back once more
when the session goes away: the frames that arrived with the last flush would
otherwise never be drawn in the window that was only watching.

Measured over one origin (two pages, 24 RAW frames): the second window read 25
frames before and 1 after (the open frame), the peak over an idle browser fell
from 1412MB to 1155MB against 944MB for a single reader.
This commit is contained in:
2026-09-30 12:58:08 +07:00
parent e0b8f14dfc
commit c9e72595f7
2 changed files with 154 additions and 6 deletions
+128 -3
View File
@@ -703,10 +703,115 @@ const tell = () => {
for (const fn of watchers) fn();
};
// --- the other windows on this origin --------------------------------------
// An installed app and a browser tab share one origin, one catalogue and one
// roll: without a word between them the second window opens, finds nothing in
// flight here, and reads the same frames again — two readers of one folder, two
// RAW decoders at 256MB apiece, for a progress the first window already has.
// So the window holding the reading says so, on every frame and on a heartbeat,
// and the windows that do not hold it take the reading as their own: they draw
// the same progress, offer the same stop, and above all start nothing.
const CHANNEL = 'recipescam-library';
const SELF = Math.random().toString(36).slice(2);
// A window that has heard nothing for this long has a peer that was closed, or
// one that is wedged — either way the reading is nobody's again, and this window
// may pick it up as it always could.
const PEER_MS = 6000;
const HEART_MS = 2000;
// One round trip, for the window that has just opened: whether another window
// holds the reading is what decides whether this one starts its own.
const ASK_MS = 250;
interface Peer {
folder: string;
progress: ScanProgress;
at: number;
}
let peer: Peer | null = null;
let peerTimer: ReturnType<typeof setTimeout> | null = null;
let heart: ReturnType<typeof setInterval> | null = null;
let askers: ((busy: boolean) => void)[] = [];
// A browser without the channel (an old one, a worker) simply has no peers, and
// everything below is a no-op there.
const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel(CHANNEL) : null;
const say = (m: Record<string, unknown>): void => {
channel?.postMessage({ ...m, from: SELF });
};
const dropPeer = (): void => {
if (peerTimer) {
clearTimeout(peerTimer);
peerTimer = null;
}
if (!peer) return;
peer = null;
tell();
};
const holdPeer = (folder: string, progress: ScanProgress): void => {
peer = { folder, progress, at: Date.now() };
for (const fn of askers) fn(true);
askers = [];
if (peerTimer) clearTimeout(peerTimer);
peerTimer = setTimeout(dropPeer, PEER_MS);
tell();
};
// A peer holds the reading the way this window holds one, as far as a screen is
// concerned: the same shape, read the same way. Whether the reading is stopped
// or jumped from here is answered by relaying it, which is why the window
// watching can offer the buttons the window reading has.
const peerSession = (): ScanSession | null =>
peer ? { folder: peer.folder, progress: peer.progress, stop: false, jump: null } : null;
const announce = (): void => {
if (live) say({ k: 'scan', folder: live.folder, progress: live.progress });
};
if (channel) {
channel.onmessage = (e: MessageEvent) => {
const m = e.data as { k?: string; from?: string; folder?: string; progress?: ScanProgress; rel?: string | null } | null;
if (!m || m.from === SELF) return;
if (m.k === 'scan' && m.folder && m.progress) holdPeer(m.folder, m.progress);
else if (m.k === 'end') {
if (peer?.folder === m.folder) dropPeer();
} else if (m.k === 'who') announce(); // a window just opened and asks who reads
else if (m.k === 'stop' && live && live.folder === m.folder) {
live.stop = true;
tell();
} else if (m.k === 'jump' && live && live.folder === m.folder) live.jump = m.rel ?? null;
};
}
// Whether another window is reading right now, asked rather than assumed: the
// window that opens beside a running scan knows nothing until the reading
// answers, and reading the disk twice over is exactly what the answer prevents.
export function scanBusy(): Promise<boolean> {
if (live) return Promise.resolve(true);
if (!channel) return Promise.resolve(false);
const answered = new Promise<boolean>((resolve) => {
const settle = (busy: boolean): void => {
askers = askers.filter((fn) => fn !== settle);
resolve(busy);
};
askers.push(settle);
// Nobody answered: no reading is running, and waiting longer only delays
// this window's own.
setTimeout(() => settle(false), ASK_MS);
});
say({ k: 'who' });
return answered;
}
// The scan in flight, or null. The same object for the whole of the scan, with
// `progress` replaced on every pass — watchers are told, so they re-read it.
// A reading another window holds answers the same way: the screens no longer
// know or care which window the reading is in.
export function scanSession(): ScanSession | null {
return live;
return live ?? peerSession();
}
// Watch, and get the unsubscribe back: the shape an effect already has.
@@ -718,13 +823,22 @@ export function watchScan(fn: () => void): () => void {
}
export function stopScan(): void {
if (!live) return;
// A reading another window holds is stopped by telling it so: the button this
// window drew is the same button, on the window that can act on it.
if (!live) {
if (peer) say({ k: 'stop', folder: peer.folder });
return;
}
live.stop = true;
tell();
}
export function jumpScan(rel: string | null): void {
if (live) live.jump = rel;
if (live) {
live.jump = rel;
return;
}
if (peer) say({ k: 'jump', folder: peer.folder, rel });
}
// Start reading a roll. Only one at a time: the walk reads one frame at a time on
@@ -740,16 +854,27 @@ export function startScan(folder: LibraryFolder): Promise<ScanProgress> {
};
live = session;
tell();
announce();
// Between two frames there is nothing to announce, and a window that hears
// silence long enough would take the reading for abandoned and start its own.
if (heart) clearInterval(heart);
heart = setInterval(announce, HEART_MS);
return scanFolder(
folder,
(p) => {
session.progress = p;
tell();
announce();
},
() => session.stop,
() => session.jump
).finally(() => {
if (heart) {
clearInterval(heart);
heart = null;
}
live = null;
say({ k: 'end', folder: session.folder });
tell();
});
}