fix(state-store): keep cleanup diagnostics off caller errors

This commit is contained in:
Yann Roberto
2026-10-06 00:36:11 +08:00
parent 8be6a6f292
commit d69eaecf25
4 changed files with 94 additions and 22 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
const { parentPort, workerData } = require('worker_threads');
const { createStateStore } = require('../state-store');
const { attachCleanupError } = require('../state-store/file-lock');
const { recordCleanupError } = require('../state-store/file-lock');
const { claimWorkItem, moveWorkItem } = require('./work-item-mutations');
/** Complete one board mutation away from the HTTP event loop, then close. */
@@ -18,7 +18,7 @@ async function mutate() {
catch (error) { failed = true; primary = error; }
try { store.close(); }
catch (error) {
if (failed) attachCleanupError(primary, 'closeError', error);
if (failed) recordCleanupError(primary, 'closeError', error);
else { failed = true; primary = error; }
}
if (failed) throw primary;
+16 -9
View File
@@ -7,12 +7,19 @@ const { performance } = require('perf_hooks');
const WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
const DEFAULT_TIMEOUT_MS = 5000;
// Cleanup must never replace an arbitrary primary throw (including frozen errors,
// primitives, or objects with throwing property traps). Diagnostics are best effort.
function attachCleanupError(primary, name, secondary) {
const CLEANUP_ERRORS = new WeakMap();
const EMPTY_CLEANUP_ERRORS = Object.freeze({});
// Preserve the original throw's identity and properties, including frozen errors
// and proxies. Primitive throws cannot own diagnostics and are left unchanged.
function recordCleanupError(primary, name, secondary) {
if (primary === null || (typeof primary !== 'object' && typeof primary !== 'function')) return;
try { Object.defineProperty(primary, name, { value: secondary, configurable: true }); }
catch (_error) { /* Preserve the primary when it cannot accept diagnostics. */ }
CLEANUP_ERRORS.set(primary, Object.freeze({ ...getCleanupErrors(primary), [name]: secondary }));
}
// Frozen snapshots are local to this module instance; they do not cross workers.
function getCleanupErrors(primary) {
return CLEANUP_ERRORS.get(primary) || EMPTY_CLEANUP_ERRORS;
}
function hasCode(error, code) {
@@ -53,7 +60,7 @@ function releaseOwnedLock(lockPath, descriptor, identity) {
fs.closeSync(descriptor); // Exactly one attempt, even after a release failure.
} catch (error) {
closeFailed = true;
if (failed) attachCleanupError(primary, 'closeError', error);
if (failed) recordCleanupError(primary, 'closeError', error);
else { failed = true; primary = error; }
}
if (pendingDelete && !closeFailed) {
@@ -100,7 +107,7 @@ function acquireLock(dbPath, timeoutMs) {
if (identity) releaseOwnedLock(lockPath, descriptor, identity);
else fs.closeSync(descriptor);
} catch (releaseError) {
attachCleanupError(error, 'releaseError', releaseError);
recordCleanupError(error, 'releaseError', releaseError);
}
throw error;
}
@@ -127,10 +134,10 @@ function withStateStoreLock(dbPath, callback, { timeoutMs = DEFAULT_TIMEOUT_MS }
release();
} catch (releaseError) {
if (!failed) throw releaseError;
attachCleanupError(primaryError, 'releaseError', releaseError);
recordCleanupError(primaryError, 'releaseError', releaseError);
}
if (failed) throw primaryError;
return result;
}
module.exports = { withStateStoreLock, attachCleanupError };
module.exports = { withStateStoreLock, recordCleanupError, getCleanupErrors };
+5 -5
View File
@@ -5,7 +5,7 @@ const crypto = require('crypto');
const os = require('os');
const path = require('path');
const initSqlJs = require('sql.js');
const { withStateStoreLock, attachCleanupError } = require('./file-lock');
const { withStateStoreLock, recordCleanupError } = require('./file-lock');
const { applyMigrations, getAppliedMigrations } = require('./migrations');
const { createQueryApi } = require('./queries');
@@ -229,7 +229,7 @@ function wrapSqlJsDatabase(SQL, dbPath) {
} catch (error) {
// The replacement is not adopted until both initialization and the old
// handle's close succeed. Never reuse an uncertain previous handle.
try { latest.close(); } catch (closeError) { attachCleanupError(error, 'closeError', closeError); }
try { latest.close(); } catch (closeError) { recordCleanupError(error, 'closeError', closeError); }
throw error;
}
rawDb = latest;
@@ -324,9 +324,9 @@ function wrapSqlJsDatabase(SQL, dbPath) {
} catch (rollbackError) {
// Never reuse an uncertain transaction, including an in-memory store.
closed = true;
try { rawDb.close(); } catch (closeError) { attachCleanupError(error, 'closeError', closeError); }
try { rawDb.close(); } catch (closeError) { recordCleanupError(error, 'closeError', closeError); }
rawDb = null;
attachCleanupError(error, 'rollbackError', rollbackError);
recordCleanupError(error, 'rollbackError', rollbackError);
}
dirty = previouslyDirty;
throw error;
@@ -398,7 +398,7 @@ async function createStateStore(options = {}) {
try {
appliedMigrations = db.withSnapshot(() => applyMigrations(db));
} catch (error) {
try { db.close(); } catch (closeError) { attachCleanupError(error, 'closeError', closeError); }
try { db.close(); } catch (closeError) { recordCleanupError(error, 'closeError', closeError); }
throw error;
}
const queryApi = createQueryApi(db);