From f9c4ace5125eff2d40963bb371a1358a3aa845b5 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 16 Jul 2026 17:54:12 +0100 Subject: [PATCH 1/2] fix(test-harness): widen android logcat filter to failure tags The runner only streamed NODEJS-MOBILE, the tag native-lib.cpp pumps node's stdout/stderr to. Two sources of failure log elsewhere and were dropped, so a broken run showed as unexplained silence: - node's own fatal-error reports go to tag `nodejs`, not through our stdout/stderr pipe, so an uncaught exception's message never appeared. - TestActivity's asset copy catches per-file errors and printStackTraces them to System.err, so a file missing on device was invisible. Diagnosing a failed rocksdb-native run needed unfiltered logcat on a local emulator to find `Error: ENOENT ... open 'test/fixtures/lock'` under `nodejs`. Add those tags plus AndroidRuntime. `-v raw` stays, so TAP output is unchanged and the sentinel parsing still works. --- test-harness/android/run-test.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test-harness/android/run-test.sh b/test-harness/android/run-test.sh index 27b4f4f..404189b 100755 --- a/test-harness/android/run-test.sh +++ b/test-harness/android/run-test.sh @@ -20,7 +20,20 @@ adb logcat -c # notices the pipe has closed when it next tries to write, and no further # lines are coming once the app has exited.) Started BEFORE the launch so # no early NODEJS-MOBILE output is missed. -coproc LOGCAT { adb logcat -v raw -s NODEJS-MOBILE:V; } +# +# Tags beyond NODEJS-MOBILE (node's stdout/stderr, pumped by native-lib.cpp) +# carry failures that are otherwise invisible here, which makes a broken run +# look like silence: +# nodejs node's own fatal-error reports, logged by nodejs-mobile's +# libnode rather than through our stdout/stderr pipe — this is +# where an uncaught exception's message actually lands. +# System.err Java stack traces. TestActivity's asset copy catches and +# prints per-file failures, so a file missing on device (and a +# resulting "cannot find addon") is only explained here. +# AndroidRuntime uncaught Java exceptions killing the app before node starts. +# `-v raw` is kept so TAP output stays clean and the sentinel parsing below is +# unchanged; extra tags interleave without a prefix. +coproc LOGCAT { adb logcat -v raw -s NODEJS-MOBILE:V nodejs:V System.err:W AndroidRuntime:E; } # Launch the activity. The app pumps node's stdout/stderr to logcat tag # NODEJS-MOBILE and emits __NODE_EXIT__: when done. Fire-and-forget, From 6969ab5dcf760c62c8547edcf8e6bc1798a4889e Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Thu, 16 Jul 2026 17:54:12 +0100 Subject: [PATCH 2/2] fix(test-harness): diagnose addon loads behind "Cannot find addon" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit require-addon raises "Cannot find addon" when every candidate fails to *load*, not only when none exist, so a prebuild that is present but cannot dlopen — built without linking libnode.so so napi_* never resolves, wrong ABI — is indistinguishable from one that was never installed. Both runners printed only err.stack, which omits the cause. Printing the cause is not enough either: the resolver assigns `cause` per candidate, so it holds the last one's error, normally "Cannot find module" for a root-level path that never existed. The real dlopen error is overwritten. The error does carry the candidate list, so retry the candidates that exist on disk and report why each failed, separating "no prebuild for this target" from "prebuild present but broken". On a rocksdb-native run this turns a misleading "Cannot find addon" into: Addon diagnostics: candidate(s) present but failed to load: .../fs-native-extensions/prebuilds/android-arm64/fs-native-extensions.node: dlopen failed: cannot locate symbol "napi_create_function" Verified on an android-arm64 emulator for the present-but-broken case, and on the host for the missing and corrupt cases. --- .../default-smoke-test.js | 62 ++++++++++++++++++- .../module-tests-runner.js | 56 ++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/.github/actions/assemble-test-project/default-smoke-test.js b/.github/actions/assemble-test-project/default-smoke-test.js index 8255f9e..7493089 100644 --- a/.github/actions/assemble-test-project/default-smoke-test.js +++ b/.github/actions/assemble-test-project/default-smoke-test.js @@ -3,8 +3,63 @@ // file the action drops next to this file at CI time, so this template is // a plain, runnable file — no placeholder substitution. +const fs = require('fs') + const { moduleName } = require('./harness-config.json') +// `err.stack` omits `cause`, so print the chain. +function errorChain(err) { + const lines = [] + let e = err + for (let depth = 0; e && depth < 5; depth++) { + if (depth > 0) lines.push('caused by:') + for (const line of String(e.stack || e).split('\n')) lines.push(line) + e = e.cause + } + return lines +} + +// require-addon raises "Cannot find addon" when every candidate fails to +// *load* — not only when none exist. A prebuild that is present but can't +// dlopen (built without linking libnode.so so napi_* never resolves, wrong +// ABI, …) therefore reports exactly like a missing file, which is precisely +// the failure this smoke test exists to catch. Its `cause` doesn't settle it +// either: the resolver overwrites `cause` per candidate, so it ends up holding +// the last candidate's error, normally a "Cannot find module" for a path that +// never existed. +// +// The candidate list is on the error, so retry the ones actually on disk and +// report why each failed. That separates "prebuild wasn't installed for this +// target" from "prebuild is installed but broken". +function addonDiagnostics(err) { + if (!err || err.code !== 'ADDON_NOT_FOUND') return [] + if (!Array.isArray(err.candidates)) return [] + + const { fileURLToPath } = require('url') + const lines = [] + + for (const candidate of err.candidates) { + let file + try { + file = fileURLToPath(candidate) + } catch { + continue // non-file: candidate (e.g. `linked:`) + } + if (!fs.existsSync(file)) continue + try { + process.dlopen({ exports: {} }, file) + lines.push(file + ': loaded on retry (original failure was elsewhere)') + } catch (e) { + lines.push(file + ': ' + (e && e.message ? e.message : e)) + } + } + + if (lines.length === 0) { + return ['no candidate exists on disk — no prebuild was installed for this target'] + } + return ['candidate(s) present but failed to load:'].concat(lines) +} + try { require(moduleName) console.log('TAP version 13') @@ -17,8 +72,13 @@ try { console.log('not ok 1 - require(' + moduleName + ') threw') console.log(' ---') console.log(' message: ' + JSON.stringify(err && err.message)) + const diagnostics = addonDiagnostics(err) + if (diagnostics.length > 0) { + console.log(' addon_diagnostics: |') + for (const line of diagnostics) console.log(' ' + line) + } console.log(' stack: |') - for (const line of String((err && err.stack) || err).split('\n')) { + for (const line of errorChain(err)) { console.log(' ' + line) } console.log(' ...') diff --git a/.github/actions/assemble-test-project/module-tests-runner.js b/.github/actions/assemble-test-project/module-tests-runner.js index 283fe2c..b12a190 100644 --- a/.github/actions/assemble-test-project/module-tests-runner.js +++ b/.github/actions/assemble-test-project/module-tests-runner.js @@ -84,8 +84,62 @@ async function main() { } } +// `err.stack` omits `cause`, so print the chain. +function formatError(err) { + const parts = [] + let e = err + for (let depth = 0; e && depth < 5; depth++) { + parts.push((depth === 0 ? '' : 'caused by: ') + String(e.stack || e)) + e = e.cause + } + return parts.join('\n') +} + +// require-addon raises "Cannot find addon" when every candidate fails to +// *load* — not only when none exist. A prebuild that is present but can't +// dlopen (built without linking libnode.so so napi_* never resolves, wrong +// ABI, …) therefore reports exactly like a missing file. Its `cause` doesn't +// settle it either: the resolver overwrites `cause` per candidate, so it ends +// up holding the last candidate's error, normally a "Cannot find module" for a +// path that never existed. +// +// The candidate list is on the error, so retry the ones actually on disk and +// report why each failed. That separates "prebuild wasn't installed for this +// target" from "prebuild is installed but broken". +function addonDiagnostics(err) { + if (!err || err.code !== 'ADDON_NOT_FOUND') return [] + if (!Array.isArray(err.candidates)) return [] + + const { fileURLToPath } = require('url') + const lines = [] + + for (const candidate of err.candidates) { + let file + try { + file = fileURLToPath(candidate) + } catch { + continue // non-file: candidate (e.g. `linked:`) + } + if (!fs.existsSync(file)) continue + try { + process.dlopen({ exports: {} }, file) + lines.push(' ' + file + ': loaded on retry (original failure was elsewhere)') + } catch (e) { + lines.push(' ' + file + ': ' + (e && e.message ? e.message : e)) + } + } + + if (lines.length === 0) { + return [ + 'Addon diagnostics: no candidate exists on disk — no prebuild was installed for this target.' + ] + } + return ['Addon diagnostics: candidate(s) present but failed to load:'].concat(lines) +} + main().catch((err) => { console.error('Fatal error loading tests from ' + moduleName + ':') - console.error(err && err.stack ? err.stack : err) + console.error(formatError(err)) + for (const line of addonDiagnostics(err)) console.error(line) process.exit(1) })