Skip to content

Commit 13b9f86

Browse files
fix(failure-log): classify errorCode in buildFailResult so failures carry a real code
Per-node hard failures built by buildFailResult() set `error` (the message) but never `errorCode`, so batch_results.error_code landed NULL and error_logs.error_code fell back to 'UNKNOWN' for every failure — the per-row failure-copy block (a product MUST) showed "Error code: UNKNOWN". The classifyProbeError() classifier already existed but was only wired into the online-scan buckets. - buildFailResult: errorCode = classifyProbeError({ message: errMsg, code: diag?.code }) (never null; worst case 'OTHER'). Aligns timedOut to match 'timed out' too. - classifyProbeError: timeout branch also matches 'timed out' (e.g. 'Node test timed out') so it returns TIMEOUT instead of OTHER. - test/error-code-classification.test.js: the three real prod failure shapes (EHOSTUNREACH / timed out / HTTP 500) + non-null fallback + static wiring. Fixes the verify:prod-db 'failure rows carry error codes' check legitimately (no loosening of the check). Does NOT touch TEST RUN paths.
1 parent d552748 commit 13b9f86

3 files changed

Lines changed: 113 additions & 3 deletions

File tree

audit/pipeline.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ function recomputeCounters(state) {
456456
function classifyProbeError(err) {
457457
const msg = (err?.message || String(err || '')).toLowerCase();
458458
const code = err?.code || '';
459-
if (msg.includes('timeout') || code === 'ETIMEDOUT' || code === 'ECONNABORTED') return 'TIMEOUT';
459+
if (msg.includes('timeout') || msg.includes('timed out') || code === 'ETIMEDOUT' || code === 'ECONNABORTED') return 'TIMEOUT';
460460
if (code === 'ENOTFOUND' || msg.includes('getaddrinfo') || msg.includes('enotfound')) return 'DNS_FAIL';
461461
if (code === 'ECONNREFUSED' || msg.includes('econnrefused')) return 'TCP_REFUSED';
462462
if (code === 'ECONNRESET' || msg.includes('econnreset')) return 'TCP_RESET';
@@ -554,7 +554,15 @@ function buildFailResult(node, status, state, errMsg, diag = {}) {
554554
sdk: state.activeSDK || 'js',
555555
os: process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'macOS' : 'Linux',
556556
error: errMsg,
557-
timedOut: /timeout/i.test(errMsg),
557+
// Classify the failure into a stable code so batch_results.error_code and
558+
// error_logs.error_code carry a real value (HOST_UNREACH / TIMEOUT /
559+
// HTTP_ERROR / …) instead of NULL / 'UNKNOWN'. Node connect errors embed the
560+
// OS code in the message ("connect EHOSTUNREACH …"); diag.code covers the
561+
// rest. Without this the per-row failure-copy block (a product MUST) shows
562+
// "Error code: UNKNOWN" for every failure. classifyProbeError never returns
563+
// null (worst case 'OTHER'), so every failure row gets a non-empty code.
564+
errorCode: classifyProbeError({ message: errMsg, code: diag?.code }),
565+
timedOut: /timeout|timed out/i.test(errMsg),
558566
diag,
559567
};
560568
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"postinstall": "node scripts/postinstall.js",
3939
"start": "node server.js",
4040
"audit": "node server.js",
41-
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js && node test/live-single-poll-gate.test.js && node test/public-sse-fields.test.js && node test/admin-sse-diag-trim.test.js && node test/eta-estimate.test.js",
41+
"test": "node test/smoke.test.js && node test/db.smoke.test.js && node test/continuous.smoke.test.js && node test/security.test.js && node test/failure-log-ux.test.js && node test/public-no-action-buttons.test.js && node test/onchain-decode.test.js && node test/admin-log-batch.test.js && node test/live-pause-overlay.test.js && node test/live-load-gating.test.js && node test/public-run-active.test.js && node test/live-single-poll-gate.test.js && node test/public-sse-fields.test.js && node test/admin-sse-diag-trim.test.js && node test/eta-estimate.test.js && node test/error-code-classification.test.js",
4242
"test:ui": "node test/ui.smoke.test.js",
4343
"test:integration": "node test/continuous.audit-integration.test.js && node test/continuous.live-db-write.test.js && node test/node-detail.smoke.test.js && node test/resume-results-seed.test.js",
4444
"verify:prod-db": "node scripts/verify-prod-db.mjs",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Sentinel Node Tester — Failure error_code classification regression tests
3+
*
4+
* Root cause this guards (2026-06): buildFailResult() set `error` (the message)
5+
* but never set `errorCode`, so batch_results.error_code landed NULL and
6+
* error_logs.error_code fell back to 'UNKNOWN' for every real failure — the
7+
* per-row failure-copy block (a product MUST) showed "Error code: UNKNOWN".
8+
* Real prod failures looked like:
9+
* connect EHOSTUNREACH 198.51.100.1:21045 -> must map to HOST_UNREACH
10+
* Node test timed out -> must map to TIMEOUT
11+
* … TKD handshake HTTP 500: {…} -> must map to HTTP_ERROR
12+
*
13+
* These tests extract the pure classifyProbeError() from pipeline.js by source
14+
* (no native imports, no DB, no server) and assert the mappings, plus a static
15+
* check that buildFailResult wires classifyProbeError into errorCode.
16+
*
17+
* Run: node test/error-code-classification.test.js
18+
*/
19+
20+
import { readFileSync } from 'fs';
21+
import { fileURLToPath } from 'url';
22+
import path from 'path';
23+
24+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
25+
const ROOT = path.join(__dirname, '..');
26+
const src = readFileSync(path.join(ROOT, 'audit/pipeline.js'), 'utf8');
27+
28+
const results = { pass: 0, fail: 0 };
29+
function ok(cond, name) {
30+
if (cond) { results.pass++; console.log(` PASS ${name}`); }
31+
else { results.fail++; console.error(` FAIL ${name}`); }
32+
}
33+
34+
// ─── Extract the pure classifyProbeError(err) function from source ───────────
35+
// It references only locals (err/msg/code) so it can be evaluated standalone.
36+
function extractFn(name) {
37+
const start = src.indexOf(`function ${name}(`);
38+
if (start === -1) throw new Error(`could not find function ${name}`);
39+
// Find the body's opening brace by first balancing the parameter-list parens,
40+
// so a default param like `diag = {}` in the signature is NOT mistaken for the
41+
// function body's `{` (that bug silently truncated buildFailResult).
42+
const parenOpen = src.indexOf('(', start);
43+
let pd = 0, bodyOpen = -1;
44+
for (let i = parenOpen; i < src.length; i++) {
45+
if (src[i] === '(') pd++;
46+
else if (src[i] === ')') { pd--; if (pd === 0) { bodyOpen = src.indexOf('{', i); break; } }
47+
}
48+
if (bodyOpen === -1) throw new Error(`could not find body open for ${name}`);
49+
let depth = 0;
50+
for (let i = bodyOpen; i < src.length; i++) {
51+
const c = src[i];
52+
if (c === '{') depth++;
53+
else if (c === '}') {
54+
depth--;
55+
if (depth === 0) {
56+
const body = src.slice(start, i + 1);
57+
if (!body.startsWith(`function ${name}(`)) throw new Error('mis-extracted ' + name);
58+
return body;
59+
}
60+
}
61+
}
62+
throw new Error(`unbalanced braces extracting ${name}`);
63+
}
64+
65+
const classifyBody = extractFn('classifyProbeError');
66+
const classifyProbeError = new Function(`${classifyBody}; return classifyProbeError;`)();
67+
68+
console.log('Failure error_code classification — regression tests\n');
69+
70+
// ─── 1. The three real prod failure shapes map to stable codes ───────────────
71+
console.log('1. observed prod failure messages map to real codes');
72+
ok(classifyProbeError({ message: 'connect EHOSTUNREACH 198.51.100.1:21045' }) === 'HOST_UNREACH',
73+
"'connect EHOSTUNREACH …' -> HOST_UNREACH");
74+
ok(classifyProbeError({ message: 'Node test timed out' }) === 'TIMEOUT',
75+
"'Node test timed out' -> TIMEOUT (the 'timed out' wording fix)");
76+
ok(classifyProbeError({ message: '409 persistent even after fresh session: TKD handshake HTTP 500: {"suc' }) === 'HTTP_ERROR',
77+
"'… HTTP 500 …' -> HTTP_ERROR");
78+
79+
// ─── 2. Code-based + other branches still classify ───────────────────────────
80+
console.log('\n2. code-based branches + non-null fallback');
81+
ok(classifyProbeError({ message: 'whatever', code: 'ETIMEDOUT' }) === 'TIMEOUT', "code ETIMEDOUT -> TIMEOUT");
82+
ok(classifyProbeError({ message: 'connect ECONNREFUSED 1.2.3.4:5' }) === 'TCP_REFUSED', "ECONNREFUSED -> TCP_REFUSED");
83+
ok(classifyProbeError({ message: 'getaddrinfo ENOTFOUND host' }) === 'DNS_FAIL', "ENOTFOUND -> DNS_FAIL");
84+
ok(classifyProbeError({ message: 'some unrecognized failure' }) === 'OTHER', "unknown -> OTHER (never null)");
85+
ok(typeof classifyProbeError({ message: '' }) === 'string' && classifyProbeError({ message: '' }).length > 0,
86+
"empty message still yields a non-empty code");
87+
88+
// ─── 3. buildFailResult wires classifyProbeError into errorCode ──────────────
89+
console.log('\n3. buildFailResult sets errorCode (static wiring check)');
90+
// Extract the exact function bodies (robust to layout/length changes) rather
91+
// than slicing fixed char windows — see code-quality review of 9fedfbe.
92+
const bfrBody = extractFn('buildFailResult');
93+
ok(/errorCode:\s*classifyProbeError\(/.test(bfrBody),
94+
'buildFailResult assigns errorCode: classifyProbeError(...)');
95+
ok(/timed out/.test(classifyBody),
96+
"classifyProbeError timeout branch also matches 'timed out'");
97+
98+
// ─── Summary ─────────────────────────────────────────────────────────────────
99+
console.log(`\n============================================================`);
100+
console.log(`RESULTS: ${results.pass} passed, ${results.fail} failed (${results.pass + results.fail} total)`);
101+
console.log(`============================================================`);
102+
process.exit(results.fail === 0 ? 0 : 1);

0 commit comments

Comments
 (0)