Skip to content

Commit 54003a1

Browse files
feat(settings): watchdog on restore + load transcript history last
Two related robustness changes to startup settings restore: - Load the (potentially large/slow) transcript history LAST, on its own await after the scalar settings have been applied and the app has booted, instead of inside the scalar Promise.all. A slow history read can no longer delay UI config restore or trip the watchdog. - Add an overall watchdog (SETTINGS_LOAD_TIMEOUT_MS = 6s) on the scalar phase. IndexedDB opens can stall indefinitely if another tab holds a versionchange; past the cap we stop waiting, log it, and boot on defaults rather than hang on an unconfigured blank state. A `booted` flag makes the watchdog and the real load mutually exclusive, so a late real load won't overwrite the defaults. Because settingsLoaded now flips before the history read lands, gate the transcript-persist effect on a new transcriptsRestoredRef: otherwise it would fire on the early settingsLoaded=true and write the still-empty in-memory array over the on-disk history (data loss for users with a large/slow history). It only persists once the read has actually populated state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d103856 commit 54003a1

1 file changed

Lines changed: 61 additions & 12 deletions

File tree

app/ui/src/App.jsx

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ const TRANSCRIPTS_KEY = 'transcripts';
187187
const getSettingsDb = () => openIdb(SETTINGS_DB_NAME, SETTINGS_STORE_NAME);
188188
const getTranscriptsDb = () => openIdb(TRANSCRIPTS_DB_NAME, TRANSCRIPTS_STORE_NAME);
189189

190+
// Watchdog cap for restoring settings on startup. The scalar settings are tiny
191+
// IndexedDB reads (sub-ms normally), so the only way they stall is a wedged DB
192+
// (e.g. another tab holding a versionchange that blocks our open). Past this we
193+
// stop waiting, log it, and boot on defaults rather than hang on a blank state.
194+
const SETTINGS_LOAD_TIMEOUT_MS = 6000;
195+
190196
async function loadSetting(key, defaultValue) {
191197
try {
192198
const value = await idbGet(await getSettingsDb(), SETTINGS_STORE_NAME, STORAGE_KEY_PREFIX + key);
@@ -859,6 +865,12 @@ export default function App() {
859865
const [showSettings, setShowSettings] = useState(false);
860866
const [showShortcuts, setShowShortcuts] = useState(false);
861867
const [settingsLoaded, setSettingsLoaded] = useState(false);
868+
// True once the (potentially large/slow) transcript history has actually been
869+
// read back into state. The history loads *after* settingsLoaded flips, so the
870+
// transcript-persist effect must wait on this too: otherwise the early
871+
// settingsLoaded=true would let it write the still-empty array over the
872+
// on-disk history before the read resolves. See the load effect below.
873+
const transcriptsRestoredRef = useRef(false);
862874
const [showConfidenceHeatmap, setShowConfidenceHeatmap] = useState(false);
863875
// Auto-copy: when enabled, every transcription is written to the system
864876
// clipboard. F-125: default OFF so the headline "audio never leaves your
@@ -901,24 +913,44 @@ export default function App() {
901913

902914
// Load settings from IndexedDB on mount
903915
useEffect(() => {
916+
// `booted` guards against the watchdog and the real load both finishing the
917+
// restore. Whichever flips it first wins; the loser bails (so a late real
918+
// load does not overwrite the defaults the watchdog already booted on).
919+
let booted = false;
920+
const watchdog = setTimeout(() => {
921+
if (booted) return;
922+
booted = true;
923+
console.warn(`[App] Settings restore timed out after ${SETTINGS_LOAD_TIMEOUT_MS}ms `
924+
+ `(IndexedDB likely blocked by another tab holding a versionchange); `
925+
+ `booting on defaults. Saved preferences were not applied this session.`);
926+
setSettingsLoaded(true);
927+
}, SETTINGS_LOAD_TIMEOUT_MS);
928+
904929
async function loadSettings() {
905930
try {
906931
// Check version first - purge old data if version mismatch
907932
const storedVersion = await loadSetting('version', null);
908-
933+
if (booted) return; // watchdog already booted on defaults; skip late restore
934+
909935
if (!storedVersion || storedVersion !== VERSION) {
910936
console.log(`[App] Version mismatch (stored: ${storedVersion}, current: ${VERSION}). Purging old data...`);
911937
await clearAllSettings();
912938
await saveSetting('version', VERSION);
939+
if (booted) return;
940+
booted = true;
941+
clearTimeout(watchdog);
913942
// Set defaults without loading old values
914943
setSettingsLoaded(true);
915944
return;
916945
}
917-
946+
947+
// Fast phase: the scalar settings are tiny reads loaded together. The
948+
// transcript history (potentially large/slow) is deliberately NOT in
949+
// this batch; it loads last, after the app boots, so it can never delay
950+
// restore or trip the watchdog.
918951
const [
919952
savedBackend,
920953
savedPreprocessor,
921-
savedTranscriptions,
922954
savedVerboseLog,
923955
savedFrameStride,
924956
savedBeamWidth,
@@ -948,7 +980,6 @@ export default function App() {
948980
] = await Promise.all([
949981
loadSetting('backend', null),
950982
loadSetting('preprocessor', 'nemo128'),
951-
loadPersistedTranscripts(),
952983
loadSetting('verboseLog', false),
953984
loadSetting('frameStride', 1),
954985
loadSetting('beamWidth', 1),
@@ -963,10 +994,8 @@ export default function App() {
963994
loadSetting('showConfidenceHeatmap', false),
964995
loadSetting('autoTranscribe', true),
965996
loadSetting('autoCopyToClipboard', false),
966-
// Migration: load with `null` so we can distinguish "user opted in/out"
967-
// from "never set, default to false". Resolved below against existing
968-
// transcript presence so we don't silently delete history of users
969-
// who upgraded into the post-F-55 build.
997+
// Load with `null` so the F-132 default below can tell "never set"
998+
// apart from an explicit choice (see the setPersistTranscripts comment).
970999
loadSetting('persistTranscripts', null),
9711000
loadSetting('showAdvancedInfo', false),
9721001
loadSetting('enableChunking', true),
@@ -980,6 +1009,7 @@ export default function App() {
9801009
loadSetting('boostCustomText', ''),
9811010
loadSetting('boostCaseInsensitive', false),
9821011
]);
1012+
if (booted) return; // watchdog won while we awaited; skip the stale restore
9831013

9841014
// A saved value means the user previously picked a backend explicitly;
9851015
// honour it (subject to the WebGPU-availability override below). When
@@ -990,7 +1020,6 @@ export default function App() {
9901020
setBackend(savedBackend);
9911021
}
9921022
setPreprocessor(savedPreprocessor);
993-
setTranscriptions(savedTranscriptions.filter(t => t.text && t.text.trim() !== ''));
9941023
setVerboseLog(savedVerboseLog);
9951024
setFrameStride(savedFrameStride);
9961025
setBeamWidth(Number.isInteger(savedBeamWidth) && savedBeamWidth >= 1 ? Math.min(25, savedBeamWidth) : 1);
@@ -1045,14 +1074,31 @@ export default function App() {
10451074
boostCustomTextRef.current = seedCustom;
10461075
setBoostSource(restoredSource);
10471076
}
1077+
// Scalar settings are in; boot the app now so the UI is configured and
1078+
// persistence/boost-init can proceed, and stop the watchdog.
1079+
booted = true;
1080+
clearTimeout(watchdog);
10481081
setSettingsLoaded(true);
1082+
1083+
// Slow phase, last: restore the transcript history. Done after booting
1084+
// so a large/slow read never blocks restore. transcriptsRestoredRef
1085+
// gates the persist effect until this lands, so the setSettingsLoaded
1086+
// above cannot write the empty in-memory array over the on-disk history.
1087+
const savedTranscriptions = await loadPersistedTranscripts();
1088+
setTranscriptions(savedTranscriptions.filter(t => t.text && t.text.trim() !== ''));
1089+
transcriptsRestoredRef.current = true;
10491090
} catch (e) {
10501091
console.error('Failed to load settings from IndexedDB:', e);
1051-
setSettingsLoaded(true);
1092+
if (!booted) {
1093+
booted = true;
1094+
clearTimeout(watchdog);
1095+
setSettingsLoaded(true);
1096+
}
10521097
}
10531098
}
1054-
1099+
10551100
loadSettings();
1101+
return () => clearTimeout(watchdog);
10561102
}, [maxCores]);
10571103

10581104
// Cleanup on component unmount
@@ -1408,7 +1454,10 @@ export default function App() {
14081454
// the prior longer array. Append-only growth uses a plain idbPut.
14091455
const prevTranscriptsLenRef = useRef(transcriptions.length);
14101456
useEffect(() => {
1411-
if (!settingsLoaded || !persistTranscripts) {
1457+
// Wait for the history read to land (transcriptsRestoredRef) before writing:
1458+
// settingsLoaded flips before the read resolves, so persisting here too early
1459+
// would clobber the on-disk history with the still-empty in-memory array.
1460+
if (!settingsLoaded || !transcriptsRestoredRef.current || !persistTranscripts) {
14121461
prevTranscriptsLenRef.current = transcriptions.length;
14131462
return;
14141463
}

0 commit comments

Comments
 (0)