mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(relay): COPY missing _seed-envelope-source + _seed-contract — chokepointFlows stale 32h (#3132)
* fix(relay): COPY _seed-envelope-source + _seed-contract into Dockerfile.relay Root cause of chokepointFlows STALE_SEED (1911min stale, maxStaleMin=720): since 2026-04-14 (PR #3097/#3101 landing), scripts/_seed-utils.mjs imports _seed-envelope-source.mjs and _seed-contract.mjs. Dockerfile.relay COPY'd _seed-utils.mjs but NOT its new transitive dependencies, so every execFile invocation of seed-chokepoint-flows.mjs, seed-climate-news.mjs, and seed-ember-electricity.mjs crashed at import with ERR_MODULE_NOT_FOUND. The ais-relay loop kept firing every 6h but each child died instantly — no visible error because execFile only surfaces child stderr to the parent relay's log stream. Local repro: node scripts/seed-chokepoint-flows.mjs runs fine in 3.6s and writes 7 records. Same command inside the relay container would throw at the import line because the file doesn't exist. Fix: 1. Add COPY scripts/_seed-envelope-source.mjs and COPY scripts/_seed-contract.mjs to Dockerfile.relay. 2. Add a static guard test (tests/dockerfile-relay-imports.test.mjs) that BFS's the transitive-import graph from every COPY'd entrypoint and fails if any reached scripts/*.mjs|cjs isn't also COPY'd. This would have caught the original regression. Matches feedback_dockerfile_relay_explicit_copy.md — we now have a test enforcing it. * fix(test): scanner also covers require() and createRequire(...)(...) — greptile P2 Review finding on PR #3132: collectRelativeImports only matched ESM import/export syntax, so require('./x.cjs') in ais-relay.cjs and createRequire(import.meta.url)('./x.cjs') in _seed-utils.mjs were invisible to the guard. No active bug (_proxy-utils.cjs is already COPY'd) but a future createRequire pointing at a new uncopied helper would slip through. Two regexes now cover both forms: - cjsRe: direct require('./x') — with a non-identifier lookbehind so 'thisrequire(' or 'foorequire(' can't match. - createRequireRe: createRequire(...)('./x') chained-call — the outer call is applied to createRequire's return value, not to a 'require(' token, so the first regex misses it on its own. Added a unit test asserting both forms resolve on known sites (_seed-utils.mjs and ais-relay.cjs) so the next edit to this file can't silently drop coverage.
This commit is contained in:
@@ -22,6 +22,15 @@ RUN npm ci --prefix scripts --omit=dev
|
||||
COPY scripts/ais-relay.cjs ./scripts/ais-relay.cjs
|
||||
COPY scripts/_proxy-utils.cjs ./scripts/_proxy-utils.cjs
|
||||
COPY scripts/_seed-utils.mjs ./scripts/_seed-utils.mjs
|
||||
# _seed-envelope-source.mjs and _seed-contract.mjs are transitively imported
|
||||
# by _seed-utils.mjs (lines 9-10) and by seed-chokepoint-flows.mjs /
|
||||
# seed-ember-electricity.mjs directly. Missing them here = silent
|
||||
# ERR_MODULE_NOT_FOUND on every execFile invocation, which looks like a hung
|
||||
# Railway cron (the initial-seed path throws, the 6h setInterval keeps firing
|
||||
# but each child dies on import). tests/dockerfile-relay-imports.test.mjs
|
||||
# guards this COPY list against future regressions.
|
||||
COPY scripts/_seed-envelope-source.mjs ./scripts/_seed-envelope-source.mjs
|
||||
COPY scripts/_seed-contract.mjs ./scripts/_seed-contract.mjs
|
||||
COPY scripts/_country-resolver.mjs ./scripts/_country-resolver.mjs
|
||||
COPY scripts/seed-climate-news.mjs ./scripts/seed-climate-news.mjs
|
||||
COPY scripts/seed-chokepoint-flows.mjs ./scripts/seed-chokepoint-flows.mjs
|
||||
|
||||
111
tests/dockerfile-relay-imports.test.mjs
Normal file
111
tests/dockerfile-relay-imports.test.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
// Static guard: every scripts/*.mjs COPY'd into the relay container must
|
||||
// have ALL its relative-path imports ALSO COPY'd. A missing transitive
|
||||
// import looks like a silent Railway cron hang — the child process dies
|
||||
// on ERR_MODULE_NOT_FOUND with output only on the parent's stderr, which
|
||||
// is easy to miss when the relay handles many other messages.
|
||||
//
|
||||
// Historical failures this test would have caught:
|
||||
// - 2026-04-14 to 2026-04-16: _seed-envelope-source.mjs added to
|
||||
// _seed-utils.mjs but not COPY'd, breaking chokepoint-flows for 32h
|
||||
// (fixed alongside PR #3128 port-activity work).
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
function readCopyList(dockerfilePath) {
|
||||
const src = readFileSync(dockerfilePath, 'utf-8');
|
||||
const copied = new Set();
|
||||
// Matches: COPY scripts/foo.mjs ./scripts/foo.mjs
|
||||
const re = /^COPY\s+(scripts\/[^\s]+\.(mjs|cjs))\s+/gm;
|
||||
for (const m of src.matchAll(re)) copied.add(m[1]);
|
||||
return copied;
|
||||
}
|
||||
|
||||
function collectRelativeImports(filePath) {
|
||||
const src = readFileSync(filePath, 'utf-8');
|
||||
const imports = new Set();
|
||||
// ESM: import ... from './x.mjs' | export ... from './x.mjs'
|
||||
const esmRe = /(?:^|\s|;)(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"](\.[^'"]+)['"]/g;
|
||||
for (const m of src.matchAll(esmRe)) imports.add(m[1]);
|
||||
// CJS direct: require('./x.cjs')
|
||||
const cjsRe = /(?:^|[^a-zA-Z0-9_$])require\s*\(\s*['"](\.[^'"]+)['"]/g;
|
||||
for (const m of src.matchAll(cjsRe)) imports.add(m[1]);
|
||||
// CJS chained: createRequire(import.meta.url)('./x.cjs')
|
||||
// — the final `('./x')` argument is applied to createRequire's return,
|
||||
// not to a `require(` token, so the cjsRe above misses it.
|
||||
const createRequireRe = /createRequire\s*\([^)]*\)\s*\(\s*['"](\.[^'"]+)['"]/g;
|
||||
for (const m of src.matchAll(createRequireRe)) imports.add(m[1]);
|
||||
return imports;
|
||||
}
|
||||
|
||||
function resolveImport(fromFile, relImport) {
|
||||
const abs = resolve(dirname(fromFile), relImport);
|
||||
if (existsSync(abs)) return abs;
|
||||
for (const ext of ['.mjs', '.cjs', '.js']) {
|
||||
if (existsSync(abs + ext)) return abs + ext;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('Dockerfile.relay — transitive-import closure', () => {
|
||||
const dockerfile = resolve(root, 'Dockerfile.relay');
|
||||
const copied = readCopyList(dockerfile);
|
||||
const entrypoints = [...copied].filter(p => p.endsWith('.mjs') || p.endsWith('.cjs'));
|
||||
|
||||
it('COPY list is non-empty (sanity)', () => {
|
||||
assert.ok(copied.size > 0, 'Dockerfile.relay has no COPY scripts/*.mjs|cjs lines');
|
||||
});
|
||||
|
||||
it('scanner catches both ESM imports and CJS require/createRequire', () => {
|
||||
// Regression guard for the scanner itself: _seed-utils.mjs has both
|
||||
// `import { ... } from './_seed-envelope-source.mjs'` (ESM) AND
|
||||
// `createRequire(import.meta.url)('./_proxy-utils.cjs')` (CJS). If
|
||||
// collectRelativeImports ever stops picking up either, a future
|
||||
// createRequire/require pointing at a new uncopied helper would slip
|
||||
// past the BFS test below without anyone noticing.
|
||||
const seedUtils = resolve(root, 'scripts/_seed-utils.mjs');
|
||||
const imports = collectRelativeImports(seedUtils);
|
||||
assert.ok(imports.has('./_seed-envelope-source.mjs'), 'ESM import not detected');
|
||||
assert.ok(imports.has('./_proxy-utils.cjs'), 'CJS createRequire not detected');
|
||||
|
||||
const relayCjs = resolve(root, 'scripts/ais-relay.cjs');
|
||||
const relayImports = collectRelativeImports(relayCjs);
|
||||
assert.ok(relayImports.has('./_proxy-utils.cjs'), 'CJS require not detected');
|
||||
});
|
||||
|
||||
// BFS the import graph from each COPY'd entrypoint. Every .mjs/.cjs reached
|
||||
// via a relative import must itself be COPY'd.
|
||||
it('every transitively-imported scripts/*.mjs|cjs is also COPY\'d', () => {
|
||||
const missing = [];
|
||||
const visited = new Set();
|
||||
const queue = entrypoints.map(p => resolve(root, p));
|
||||
while (queue.length) {
|
||||
const file = queue.shift();
|
||||
if (visited.has(file)) continue;
|
||||
visited.add(file);
|
||||
if (!existsSync(file)) continue;
|
||||
for (const rel of collectRelativeImports(file)) {
|
||||
const resolved = resolveImport(file, rel);
|
||||
if (!resolved) continue;
|
||||
const relToRoot = resolved.startsWith(root + '/') ? resolved.slice(root.length + 1) : null;
|
||||
if (!relToRoot || !relToRoot.startsWith('scripts/')) continue;
|
||||
if (!copied.has(relToRoot)) {
|
||||
missing.push(`${relToRoot} (imported by ${file.slice(root.length + 1)})`);
|
||||
}
|
||||
queue.push(resolved);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`Dockerfile.relay is missing COPY lines for:\n ${missing.join('\n ')}\n` +
|
||||
`Add a 'COPY <path> ./<path>' line per missing file.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user