feat: Auto-detect and rebuild native modules on Node.js version changes (#149)

Implements three-layer defense against native module version mismatches:

Layer 1: Node.js Version Tracking
- Track Node.js version alongside package version in .install-version marker
- Auto-trigger npm install when Node.js version changes
- Backward compatible with old plain-text version marker format

Layer 2: Native Module Verification
- Add verifyNativeModules() function to test better-sqlite3 loads correctly
- Verify after install completes to catch corrupted builds
- Retry with force flag if initial install verification fails

Layer 3: Graceful Failure
- Catch ERR_DLOPEN_FAILED in context-hook and delete version marker
- Exit cleanly to avoid error spam in Claude Code UI
- Auto-fix on next session start

Changes:
- scripts/smart-install.js: Add Node.js version tracking and verification
- src/hooks/context-hook.ts: Add graceful failure handling for native module errors
- tests/smart-install.test.js: Add tests for version marker format compatibility
- plugin/scripts/context-hook.js: Built output from TypeScript source

Fixes the issue where users see ERR_DLOPEN_FAILED errors after Node.js upgrades,
requiring manual npm install. Now automatically detects and fixes the issue.

Related design doc: docs/context/native-module-auto-fix-design.md
Implementation plan: docs/context/native-module-auto-fix-implementation.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alex Newman <thedotmack@gmail.com>
This commit is contained in:
Dmytro Gaivoronsky
2025-11-30 14:28:07 -08:00
committed by GitHub
parent de279ef6bf
commit 69b17e15a2
5 changed files with 221 additions and 37 deletions
+4
View File
@@ -1484,6 +1484,7 @@
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -2337,6 +2338,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -3849,6 +3851,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4850,6 +4853,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
File diff suppressed because one or more lines are too long
+119 -16
View File
@@ -51,10 +51,31 @@ function getPackageVersion() {
}
}
function getNodeVersion() {
return process.version; // e.g., "v22.21.1"
}
function getInstalledVersion() {
try {
if (existsSync(VERSION_MARKER_PATH)) {
return readFileSync(VERSION_MARKER_PATH, 'utf-8').trim();
const content = readFileSync(VERSION_MARKER_PATH, 'utf-8').trim();
// Try parsing as JSON (new format)
try {
const marker = JSON.parse(content);
return {
packageVersion: marker.packageVersion,
nodeVersion: marker.nodeVersion,
installedAt: marker.installedAt
};
} catch {
// Fallback: old format (plain text version string)
return {
packageVersion: content,
nodeVersion: null, // Unknown
installedAt: null
};
}
}
} catch (error) {
// Marker doesn't exist or can't be read
@@ -62,9 +83,14 @@ function getInstalledVersion() {
return null;
}
function setInstalledVersion(version) {
function setInstalledVersion(packageVersion, nodeVersion) {
try {
writeFileSync(VERSION_MARKER_PATH, version, 'utf-8');
const marker = {
packageVersion,
nodeVersion,
installedAt: new Date().toISOString()
};
writeFileSync(VERSION_MARKER_PATH, JSON.stringify(marker, null, 2), 'utf-8');
} catch (error) {
log(`⚠️ Failed to write version marker: ${error.message}`, colors.yellow);
}
@@ -84,24 +110,77 @@ function needsInstall() {
}
// Check version marker
const currentVersion = getPackageVersion();
const installedVersion = getInstalledVersion();
const currentPackageVersion = getPackageVersion();
const currentNodeVersion = getNodeVersion();
const installed = getInstalledVersion();
if (!installedVersion) {
if (!installed) {
log('📦 No version marker found - installing', colors.cyan);
return true;
}
if (currentVersion !== installedVersion) {
log(`📦 Version changed (${installedVersion}${currentVersion}) - updating`, colors.cyan);
// Check package version
if (currentPackageVersion !== installed.packageVersion) {
log(`📦 Version changed (${installed.packageVersion}${currentPackageVersion}) - updating`, colors.cyan);
return true;
}
// Check Node.js version
if (installed.nodeVersion && currentNodeVersion !== installed.nodeVersion) {
log(`📦 Node.js version changed (${installed.nodeVersion}${currentNodeVersion}) - rebuilding native modules`, colors.cyan);
return true;
}
// If old format (no nodeVersion), assume needs install
if (!installed.nodeVersion) {
log('📦 Old version marker format - updating', colors.cyan);
return true;
}
// All good - no install needed
log(`✓ Dependencies already installed (v${currentVersion})`, colors.dim);
log(`✓ Dependencies already installed (v${currentPackageVersion})`, colors.dim);
return false;
}
/**
* Verify that better-sqlite3 native module loads correctly
* This catches ABI mismatches and corrupted builds
*/
async function verifyNativeModules() {
try {
log('🔍 Verifying native modules...', colors.dim);
// Try to actually load better-sqlite3
const { default: Database } = await import('better-sqlite3');
// Try to create a test in-memory database
const db = new Database(':memory:');
// Run a simple query to ensure it works
const result = db.prepare('SELECT 1 + 1 as result').get();
// Clean up
db.close();
if (result.result !== 2) {
throw new Error('SQLite math check failed');
}
log('✓ Native modules verified', colors.dim);
return true;
} catch (error) {
if (error.code === 'ERR_DLOPEN_FAILED') {
log('⚠️ Native module ABI mismatch detected', colors.yellow);
return false;
}
// Other errors are unexpected - log and fail
log(`❌ Native module verification failed: ${error.message}`, colors.red);
return false;
}
}
function getWindowsErrorHelp(errorOutput) {
// Detect Python version at runtime
let pythonStatus = ' Python not detected or version unknown';
@@ -157,7 +236,7 @@ function getWindowsErrorHelp(errorOutput) {
return help.join('\n');
}
function runNpmInstall() {
async function runNpmInstall() {
const isWindows = process.platform === 'win32';
log('', colors.cyan);
@@ -175,7 +254,7 @@ function runNpmInstall() {
for (const { command, label } of strategies) {
try {
log(`Attempting install ${label}...`, colors.dim);
// Run npm install silently
execSync(command, {
cwd: PLUGIN_ROOT,
@@ -188,12 +267,20 @@ function runNpmInstall() {
throw new Error('better-sqlite3 installation verification failed');
}
const version = getPackageVersion();
setInstalledVersion(version);
// NEW: Verify native modules actually work
const nativeModulesWork = await verifyNativeModules();
if (!nativeModulesWork) {
throw new Error('Native modules failed to load after install');
}
const packageVersion = getPackageVersion();
const nodeVersion = getNodeVersion();
setInstalledVersion(packageVersion, nodeVersion);
log('', colors.green);
log('✅ Dependencies installed successfully!', colors.bright);
log(` Version: ${version}`, colors.dim);
log(` Package version: ${packageVersion}`, colors.dim);
log(` Node.js version: ${nodeVersion}`, colors.dim);
log('', colors.reset);
return true;
@@ -251,8 +338,8 @@ async function main() {
const installNeeded = needsInstall();
if (installNeeded) {
// Run installation
const installSuccess = runNpmInstall();
// Run installation (now async)
const installSuccess = await runNpmInstall();
if (!installSuccess) {
log('', colors.red);
@@ -260,6 +347,22 @@ async function main() {
log('', colors.reset);
process.exit(1);
}
} else {
// NEW: Even if install not needed, verify native modules work
const nativeModulesWork = await verifyNativeModules();
if (!nativeModulesWork) {
log('📦 Native modules need rebuild - reinstalling', colors.cyan);
const installSuccess = await runNpmInstall();
if (!installSuccess) {
log('', colors.red);
log('⚠️ Native module rebuild failed', colors.yellow);
log('', colors.reset);
process.exit(1);
}
}
}
// Try to start the PM2 worker after fresh install
try {
+32 -2
View File
@@ -5,10 +5,20 @@
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync } from 'fs';
import { existsSync, readFileSync, unlinkSync } from 'fs';
import { stdin } from 'process';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { SessionStore } from '../services/sqlite/SessionStore.js';
// Get __dirname equivalent in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Version marker path (same as smart-install.js)
// From src/hooks/ we need to go up to plugin root: ../../
const VERSION_MARKER_PATH = path.join(__dirname, '../../.install-version');
/**
* Get context depth from settings
* Priority: ~/.claude/settings.json > env var > default
@@ -156,7 +166,27 @@ async function contextHook(input?: SessionStartInput, useColors: boolean = false
const cwd = input?.cwd ?? process.cwd();
const project = cwd ? path.basename(cwd) : 'unknown-project';
const db = new SessionStore();
let db: SessionStore;
try {
db = new SessionStore();
} catch (error: any) {
if (error.code === 'ERR_DLOPEN_FAILED') {
// Native module ABI mismatch - delete version marker to trigger reinstall
try {
unlinkSync(VERSION_MARKER_PATH);
} catch (unlinkError) {
// Marker might not exist, that's okay
}
// Log once (not error spam) and exit cleanly
console.error('⚠️ Native module rebuild needed - restart Claude Code to auto-fix');
console.error(' (This happens after Node.js version upgrades)');
process.exit(0); // Exit cleanly to avoid error spam
}
// Other errors should still throw
throw error;
}
// Get ALL recent observations for this project (not filtered by summaries)
// This ensures we show observations even when summaries haven't been generated
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert';
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
import { join } from 'path';
const VERSION_MARKER_PATH = join(process.cwd(), '.install-version');
test('version marker - new JSON format', () => {
const marker = {
packageVersion: '6.3.2',
nodeVersion: 'v22.21.1',
installedAt: new Date().toISOString()
};
writeFileSync(VERSION_MARKER_PATH, JSON.stringify(marker, null, 2));
const content = JSON.parse(readFileSync(VERSION_MARKER_PATH, 'utf-8'));
assert.strictEqual(content.packageVersion, '6.3.2');
assert.strictEqual(content.nodeVersion, 'v22.21.1');
assert.ok(content.installedAt);
unlinkSync(VERSION_MARKER_PATH);
});
test('version marker - backward compatibility with old format', () => {
// Old format: plain text version string
writeFileSync(VERSION_MARKER_PATH, '6.3.2');
const content = readFileSync(VERSION_MARKER_PATH, 'utf-8').trim();
// Should be able to parse old format
let marker;
try {
marker = JSON.parse(content);
} catch {
// Old format - create compatible object
marker = {
packageVersion: content,
nodeVersion: null,
installedAt: null
};
}
assert.strictEqual(marker.packageVersion, '6.3.2');
assert.strictEqual(marker.nodeVersion, null);
unlinkSync(VERSION_MARKER_PATH);
});