mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-19 08:33:56 -06:00
TWO CHANGES, together because they touch the same packager hunks. 1. THE DRIVER. The BrightSign package used to be MANUFACTURED. scripts/build-server-zip.sh dropped better-sqlite3 from package.json and then installed db/sqlite-compat.js into node_modules under that name, so every require resolved to the façade. It worked — and it shipped a database layer that no test had ever executed. That is the same shape as the TELEMETRY_COLLECTOR TDZ crash that took production down while 1676 tests and four CI jobs were green: a build-time rewrite cannot be tested by the build that performs it. db/sqlite-driver.js now decides at runtime: the native driver when it loads, the node:sqlite façade otherwise. One artifact, one code path, and — the point — both branches reachable from a test. ST_SQLITE_DRIVER=node runs the entire suite the way a player runs it, and a new CI job does exactly that on Node 24 with --omit=optional so the fallback is reached the same way it is on hardware, not by an env var alone. better-sqlite3 becomes an optionalDependency, so a host with no compiler installs cleanly and falls back rather than failing. preflight-deps stops trying to rebuild a native module on a host that has no toolchain and a working built-in driver — on a player that was a five-minute node-gyp failure ending in a server that never started. Asking for the native driver BY NAME (ST_SQLITE_DRIVER=better-sqlite3) still fails loudly, because a production box that has lost its native module is broken and should say so rather than quietly running something else. ⚠️ NODE 24 IN PRACTICE. node:sqlite is unflagged only from 23.4; on the 22.x line it needs --experimental-sqlite and on 20.x it does not exist. So the code probes rather than comparing versions, the player package pins engines >=24, and the built-in cases skip on the Node 20 CI job rather than failing there. Verified on Node 24, both drivers, full suite: better-sqlite3 1762 pass / 0 fail node:sqlite 1762 pass / 0 fail and the built payload resolves node:sqlite with no better-sqlite3 present at all. 2. THE LICENCE. The ffprobe/ffmpeg binaries added in the previous commit are LGPL 2.1 and statically linked, so the licence text has to travel WITH them — a link on a website is not the copy the licence asks to accompany the work. The packager now copies COPYING.LGPLv2.1 and a build README into bin/, and refuses to build if the licence is missing. legal/third-party.html gains an LGPL section with the written offer required by section 6 for static linking, and the exact configure line. It also drops Sharp, which that page still listed although #263 removed it, and names what actually does the image work now (jimp, @jsquash/webp, @jsquash/avif). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
104 lines
5.4 KiB
JavaScript
104 lines
5.4 KiB
JavaScript
// WAL checkpointer WORKER (worker_threads). Runs OFF the main event-loop thread so the
|
|
// synchronous, fsync-heavy checkpoint never blocks the loop (the ~60s p99 spike).
|
|
//
|
|
// CRITICAL: this worker opens its OWN better-sqlite3 Database() handle against the same
|
|
// file. better-sqlite3 handles are NOT thread-safe, so the main thread's handle is never
|
|
// shared into the worker — only the dbPath STRING is passed via workerData. SQLite WAL is
|
|
// designed for multiple connections to the same file, so a second connection checkpointing
|
|
// while the main connection writes is safe.
|
|
const { workerData, parentPort } = require('worker_threads');
|
|
const fs = require('fs');
|
|
const { Database } = require('./sqlite-driver');
|
|
|
|
const { dbPath, intervalMs, highWaterBytes, starvationRuns, starvationFloorBytes, escalateCooldownMs } = workerData;
|
|
|
|
// Fault injection for TESTS ONLY (env-gated; inert in prod). Exits immediately on start so
|
|
// the controller's respawn / autocheckpoint-fallback path can be exercised deterministically.
|
|
if (process.env.WAL_CKPT_FAIL_START) process.exit(1);
|
|
|
|
// Fresh, worker-owned connection (NOT the main handle).
|
|
const db = new Database(dbPath);
|
|
db.pragma('busy_timeout = 5000'); // wait (on THIS worker thread) through the main writer's brief locks
|
|
db.pragma('wal_autocheckpoint = 0'); // this connection must never auto-checkpoint either
|
|
|
|
const walFile = dbPath + '-wal';
|
|
function walBytes() { try { return fs.statSync(walFile).size; } catch { return 0; } }
|
|
|
|
let lastBytes = 0;
|
|
let growthRuns = 0; // consecutive PASSIVE runs where the WAL failed to shrink
|
|
let lastTruncateAt = 0; // #240: when we last blocked for a TRUNCATE (0 = never)
|
|
let coolingReported = false;
|
|
let timer = null;
|
|
|
|
function tick() {
|
|
try {
|
|
// PASSIVE never blocks writers, but skips frames pinned by active readers/writers —
|
|
// so on its own it can perpetually under-checkpoint. That's what the guard below bounds.
|
|
db.pragma('wal_checkpoint(PASSIVE)', { simple: false });
|
|
const bytes = walBytes();
|
|
|
|
// --- STARVATION BOUND (this is where "WAL cannot grow forever" is enforced) ---
|
|
// Escalating forces a TRUNCATE, which BLOCKS until it has checkpointed everything and
|
|
// truncated the file to 0. #240: "fine here on the worker" was only ever half true —
|
|
// the fsync is off the loop, but SQLite's locks are held across CONNECTIONS, so the
|
|
// main thread's next statement waits it out in the busy handler. Hence the gates below.
|
|
if (bytes > lastBytes) growthRuns++; else growthRuns = 0;
|
|
const overHighWater = bytes > highWaterBytes;
|
|
// #240: TRUNCATE blocks ACROSS connections — the main thread's next statement waits in
|
|
// SQLite's busy handler for the whole checkpoint — so the growth signal alone must not
|
|
// be able to spend it. Two gates, because either on its own leaves the hole open:
|
|
// FLOOR: a WAL in the lower half of its budget has little to reclaim; blocking for it
|
|
// is pure cost. (Ungated, every morning fleet power-on wave bought a loop stall.)
|
|
// COOLDOWN: a WAL that already sits ABOVE the floor would otherwise escalate on every
|
|
// burst forever. However long the pressure lasts, we stall the loop at most once
|
|
// per window and let PASSIVE do the rest.
|
|
// overHighWater bypasses both — a runaway WAL is the one case worth blocking for.
|
|
const sinceLast = Date.now() - lastTruncateAt;
|
|
const starved = growthRuns >= starvationRuns && bytes >= starvationFloorBytes;
|
|
const cooling = starved && lastTruncateAt > 0 && sinceLast < escalateCooldownMs;
|
|
|
|
if (cooling && !overHighWater) {
|
|
// Report the transition only — a starved-and-cooling state persists for the whole
|
|
// window and this check runs every interval; one line, not a log flood.
|
|
if (!coolingReported) {
|
|
coolingReported = true;
|
|
post(`starvation escalation held off (WAL ${(bytes / 1e6).toFixed(1)}MB, last TRUNCATE ${Math.round(sinceLast / 1000)}s ago) — PASSIVE continues`);
|
|
}
|
|
lastBytes = bytes;
|
|
return;
|
|
}
|
|
|
|
if (overHighWater || starved) {
|
|
lastTruncateAt = Date.now();
|
|
coolingReported = false;
|
|
const r = db.pragma('wal_checkpoint(TRUNCATE)', { simple: false });
|
|
const after = walBytes();
|
|
// #240: TRUNCATE does NOT throw when it can't get the locks — it returns busy=1 having
|
|
// sat on SQLite's busy timeout for its full duration. Measured at ~4.9s with a single
|
|
// reader mid-transaction, reclaiming nothing, while every main-thread statement waited
|
|
// behind it. Say so plainly: a silent 5-second loss is the worst thing this can do.
|
|
const busy = Array.isArray(r) && r[0] && r[0].busy === 1;
|
|
post(`escalated TRUNCATE (${overHighWater ? 'high-water' : 'starvation'}): WAL ${(bytes / 1e6).toFixed(1)}MB -> ${(after / 1e6).toFixed(1)}MB${busy ? ' — BUSY: reclaimed nothing, blocked writers for the busy timeout' : ''}`);
|
|
growthRuns = 0;
|
|
lastBytes = after;
|
|
} else {
|
|
lastBytes = bytes;
|
|
}
|
|
} catch (e) {
|
|
post('checkpoint error: ' + (e && e.message));
|
|
}
|
|
}
|
|
|
|
function post(log) { try { parentPort && parentPort.postMessage({ log }); } catch (_) {} }
|
|
|
|
timer = setInterval(tick, intervalMs);
|
|
|
|
// Clean shutdown: stop the timer, close our connection, exit THIS worker thread.
|
|
parentPort && parentPort.on('message', (m) => {
|
|
if (m && m.stop) {
|
|
if (timer) { clearInterval(timer); timer = null; }
|
|
try { db.close(); } catch (_) {}
|
|
process.exit(0);
|
|
}
|
|
});
|