From f5794ff26a2461a7c0bca36d72717bb5e11dcf80 Mon Sep 17 00:00:00 2001 From: BarnsL <252321079+BarnsL@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:19:53 -0700 Subject: [PATCH 1/2] fix: add heal script to clear stale LadybugDB WAL files that brick graph loads Aborting a graph stream mid-flight leaves a small lbug.wal file. Every subsequent open then fails on a missing lbug.shadow, and the web UI permanently shows "Graph not loaded" until the WAL is manually removed. This script reads ~/.gitnexus/registry.json, finds WAL files at or below the header-only size threshold (4 KB), backs them up to ~/.gitnexus/wal-backups/, and unlinks them. It refuses to run while `gitnexus analyze` is in flight to avoid truncating a real transaction. Usage: node gitnexus/scripts/heal-stale-wal.mjs # heal all node gitnexus/scripts/heal-stale-wal.mjs --check # dry run --- gitnexus/scripts/heal-stale-wal.mjs | 155 ++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 gitnexus/scripts/heal-stale-wal.mjs diff --git a/gitnexus/scripts/heal-stale-wal.mjs b/gitnexus/scripts/heal-stale-wal.mjs new file mode 100644 index 000000000..628f085a3 --- /dev/null +++ b/gitnexus/scripts/heal-stale-wal.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +/** + * heal-stale-wal.mjs — Clear stale LadybugDB WAL files that prevent graph loads. + * + * PROBLEM + * ------- + * Aborting a graph stream mid-flight (navigating away, reloading the page, or + * closing the tab before the stream finishes) leaves a small `lbug.wal` file in + * the index's storage directory. Every subsequent attempt to open the database + * then fails because LadybugDB tries to recover through `lbug.shadow`, which + * was never written: + * + * {"type":"error","error":"IO exception: Cannot open file. + * path: .../.gitnexus/lbug.shadow - Error 2: The system cannot find the file + * specified."} + * + * The web UI receives this as its first stream frame, so the progress bar sits + * at "0.0 MB downloaded" and falls back to "Graph not loaded". It is not a size + * problem and not a browser problem: the index stays bricked until the WAL is + * removed. + * + * USAGE + * node gitnexus/scripts/heal-stale-wal.mjs # heal every registered index + * node gitnexus/scripts/heal-stale-wal.mjs --check # report only, change nothing + * + * SAFETY + * ------ + * A WAL is only junk when nothing is writing. `gitnexus analyze` writes, so this + * script refuses to run while an analyze is in flight. It also copies each WAL to + * a backup directory before unlinking, and only targets WAL files at or below a + * header-only size threshold (4 KB) to avoid touching legitimate transactions. + */ + +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, copyFileSync, unlinkSync, readFileSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; + +const REGISTRY_PATH = join(homedir(), '.gitnexus', 'registry.json'); +const BACKUP_DIR = join(homedir(), '.gitnexus', 'wal-backups'); +const HEADER_ONLY_BYTES = 4096; + +function isAnalyzeRunning() { + try { + const output = execSync( + process.platform === 'win32' + ? 'tasklist /FO CSV /NH' + : 'ps aux', + { encoding: 'utf-8', timeout: 5000 }, + ); + return output.includes('gitnexus') && output.includes('analyze'); + } catch { + return false; + } +} + +function getStoragePaths() { + if (!existsSync(REGISTRY_PATH)) return []; + let entries; + try { + entries = JSON.parse(readFileSync(REGISTRY_PATH, 'utf-8')); + } catch { + return []; + } + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + if (typeof entry !== 'object' || entry === null) continue; + const raw = entry.storagePath || entry.path; + if (raw) out.push({ name: entry.name || '?', storagePath: resolve(String(raw)) }); + } + return out; +} + +function formatTimestamp() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; +} + +function heal({ checkOnly = false } = {}) { + if (isAnalyzeRunning()) { + console.log('gitnexus analyze is running; refusing to touch any WAL.'); + return 0; + } + + const indexes = getStoragePaths(); + if (indexes.length === 0) { + console.log('No indexes found in registry.'); + return 0; + } + + let cleared = 0; + + for (const { name, storagePath } of indexes) { + const walPath = join(storagePath, 'lbug.wal'); + + if (!existsSync(walPath)) { + console.log(` ${name}: clean`); + continue; + } + + let size; + try { + size = statSync(walPath).size; + } catch { + console.log(` ${name}: could not stat WAL`); + continue; + } + + if (size > HEADER_ONLY_BYTES) { + console.log( + ` ${name}: WAL is ${size.toLocaleString()} bytes — too large to assume it is ` + + `an aborted read. Left alone; re-run \`gitnexus analyze\` instead.`, + ); + continue; + } + + if (checkOnly) { + console.log(` ${name}: STALE WAL (${size} bytes) — would clear`); + cleared++; + continue; + } + + try { + mkdirSync(BACKUP_DIR, { recursive: true }); + const backupName = `${name}-${formatTimestamp()}.wal`; + copyFileSync(walPath, join(BACKUP_DIR, backupName)); + unlinkSync(walPath); + console.log(` ${name}: cleared stale WAL (${size} bytes)`); + cleared++; + } catch (err) { + console.log(` ${name}: could not clear WAL (${err.message}). Is the server holding it?`); + } + } + + return cleared; +} + +const args = process.argv.slice(2); +const checkOnly = args.includes('--check'); + +if (!existsSync(REGISTRY_PATH)) { + console.log('No GitNexus registry found at ~/.gitnexus/registry.json'); + process.exit(0); +} + +const count = heal({ checkOnly }); + +if (count > 0 && !checkOnly) { + console.log(`\n${count} index(es) healed. Reload the GitNexus tab.`); +} else if (count === 0) { + console.log('\nNothing to heal.'); +} From 3c7b9f97ee2f1339d9b9c9a42731a04511ce0424 Mon Sep 17 00:00:00 2001 From: BarnsL <252321079+BarnsL@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:02:58 -0700 Subject: [PATCH 2/2] fix: address review findings from gitnexus-check - Honor GITNEXUS_HOME env var for registry/backup paths (Docker support) - Disambiguate backup filenames when duplicate aliases exist (index + PID suffix to prevent overwrites) - Document the TOCTOU race as benign: Windows EBUSY and POSIX fd semantics both protect against concurrent analyze --- gitnexus/scripts/heal-stale-wal.mjs | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/gitnexus/scripts/heal-stale-wal.mjs b/gitnexus/scripts/heal-stale-wal.mjs index 628f085a3..b10742148 100644 --- a/gitnexus/scripts/heal-stale-wal.mjs +++ b/gitnexus/scripts/heal-stale-wal.mjs @@ -30,6 +30,11 @@ * script refuses to run while an analyze is in flight. It also copies each WAL to * a backup directory before unlinking, and only targets WAL files at or below a * header-only size threshold (4 KB) to avoid touching legitimate transactions. + * + * The process scan is a courtesy pre-check, not a lock. An analyze could start + * between the check and the unlink. On Windows the unlink will fail with EBUSY + * if the WAL is held open, making the race benign. On POSIX the fd stays valid + * after unlink, so the writer is also unaffected. */ import { execSync } from 'node:child_process'; @@ -37,8 +42,9 @@ import { existsSync, mkdirSync, copyFileSync, unlinkSync, readFileSync, statSync import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; -const REGISTRY_PATH = join(homedir(), '.gitnexus', 'registry.json'); -const BACKUP_DIR = join(homedir(), '.gitnexus', 'wal-backups'); +const GITNEXUS_DIR = process.env.GITNEXUS_HOME || join(homedir(), '.gitnexus'); +const REGISTRY_PATH = join(GITNEXUS_DIR, 'registry.json'); +const BACKUP_DIR = join(GITNEXUS_DIR, 'wal-backups'); const HEADER_ONLY_BYTES = 4096; function isAnalyzeRunning() { @@ -79,6 +85,15 @@ function formatTimestamp() { return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } +function uniqueBackupPath(name, index) { + mkdirSync(BACKUP_DIR, { recursive: true }); + const ts = formatTimestamp(); + const suffix = index > 0 ? `-${index}` : ''; + const candidate = join(BACKUP_DIR, `${name}${suffix}-${ts}.wal`); + if (!existsSync(candidate)) return candidate; + return join(BACKUP_DIR, `${name}${suffix}-${ts}-${process.pid}.wal`); +} + function heal({ checkOnly = false } = {}) { if (isAnalyzeRunning()) { console.log('gitnexus analyze is running; refusing to touch any WAL.'); @@ -93,7 +108,8 @@ function heal({ checkOnly = false } = {}) { let cleared = 0; - for (const { name, storagePath } of indexes) { + for (let i = 0; i < indexes.length; i++) { + const { name, storagePath } = indexes[i]; const walPath = join(storagePath, 'lbug.wal'); if (!existsSync(walPath)) { @@ -124,9 +140,8 @@ function heal({ checkOnly = false } = {}) { } try { - mkdirSync(BACKUP_DIR, { recursive: true }); - const backupName = `${name}-${formatTimestamp()}.wal`; - copyFileSync(walPath, join(BACKUP_DIR, backupName)); + const backupPath = uniqueBackupPath(name, i); + copyFileSync(walPath, backupPath); unlinkSync(walPath); console.log(` ${name}: cleared stale WAL (${size} bytes)`); cleared++; @@ -142,7 +157,7 @@ const args = process.argv.slice(2); const checkOnly = args.includes('--check'); if (!existsSync(REGISTRY_PATH)) { - console.log('No GitNexus registry found at ~/.gitnexus/registry.json'); + console.log(`No GitNexus registry found at ${REGISTRY_PATH}`); process.exit(0); }