fix(dictionary): stop character dictionary IO from blocking the main process

Generating and importing a large character dictionary froze the whole
app long enough for the compositor to raise its application-not-
responding dialog over the player. Multi-hundred-MB snapshot JSONs and
the merged archive were read, written, and zipped synchronously on the
main process, and the character image / name-candidate caches re-read
every cached snapshot synchronously inside a lookup whenever the
snapshot directory changed.

- snapshot reads/writes are async; writes stream in slices and rename
  into place so a crash or concurrent writer cannot tear a snapshot
- buildDictionaryZip yields between ~8MB slices and CRC32 uses the
  native zlib implementation
- the image and name-candidate lookup caches rebuild in the background
  and serve the previous index while the rebuild runs

Worst main-thread stall over a 1.4GB snapshot set drops from 8s+ to
under 700ms.
This commit is contained in:
2026-08-17 01:45:54 -07:00
parent ec7f0345b0
commit 1228ebe622
12 changed files with 361 additions and 151 deletions
@@ -42,7 +42,7 @@ function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
return entries;
}
test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', () => {
test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', async () => {
const tempDir = makeTempDir();
const outputPath = path.join(tempDir, 'dictionary.zip');
const termEntries: CharacterDictionaryTermEntry[] = [
@@ -62,7 +62,7 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
);
}) as typeof Buffer.concat;
const result = buildDictionaryZip(
const result = await buildDictionaryZip(
outputPath,
'Dictionary Title',
'Dictionary Description',
@@ -106,11 +106,11 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
}
});
test('readDictionaryZipRevision reads the built revision and rejects foreign archives', () => {
test('readDictionaryZipRevision reads the built revision and rejects foreign archives', async () => {
const dir = makeTempDir();
try {
const zipPath = path.join(dir, 'merged.zip');
buildDictionaryZip(
await buildDictionaryZip(
zipPath,
'SubMiner Character Dictionary',
'Character names',