Repository navigation
await on namespace-member that returns undefined re-enters catch arm infinitely (5.7M lines observed) #712
Description
What happened
Yesterday during the parity sweep on v0.5.836, the following test file produced 5,737,500 lines of identical output (setTimeout error: value is not a function repeating) and hung the runner's normalize_output bash loop for ~3 hours.
Repro code (the file that triggered it on v0.5.836)
import * as timers_promises from "node:timers/promises";
async function main() {
try {
console.log("setTimeout typeof:", typeof timers_promises.setTimeout);
console.log("setImmediate typeof:", typeof timers_promises.setImmediate);
console.log("setInterval typeof:", typeof timers_promises.setInterval);
} catch (e) {
console.log("exports error:", (e as Error).message);
}
try {
const v = await timers_promises.setTimeout(0, "value");
console.log("setTimeout result:", v);
} catch (e) {
console.log("setTimeout error:", (e as Error).message);
}
}
main();Observed output on v0.5.836
setTimeout typeof: boolean
setImmediate typeof: boolean
setInterval typeof: boolean
setTimeout error: value is not a function
setTimeout error: value is not a function
setTimeout error: value is not a function
... (repeated 5,737,497 more times until killed)
So the first try/catch (typeof probes) ran fine. The second try/catch — await timers_promises.setTimeout(0, "value") where timers_promises.setTimeout resolves to undefined at runtime — entered the catch arm, printed setTimeout error: value is not a function, and then re-entered the catch arm forever.
Expected
The catch arm runs once, prints the error, control falls through to the rest of main(), the program exits.
Status on v0.5.854 (today)
The trigger pattern (import * as X from "node:X" for partially-recognized paths) is rejected at compile time by v0.5.835's strict namespace-import policy:
Error: Could not resolve namespace import `import * as ... from "timers/promises"`.
So the trigger path I observed is blocked, but the underlying runtime bug — that an await on undefined inside try/catch can cause the catch arm to re-enter infinitely — may still be reachable through other code paths (e.g. compilePackages-produced JS that resolves an FFI symbol to undefined at runtime, dynamic property access that returns undefined inside await, etc.).
Reproduction attempts that don't trigger it (v0.5.854)
The simple form doesn't trigger:
const fn = undefined as any;
try { await fn(0, "value"); } catch (e) { console.log("caught:", (e as Error).message); }
console.log("after");This runs cleanly: prints "caught: ..." once, then "after". No loop.
The named-import-with-as-any-cast form also runs cleanly:
import { setTimeout as _setTimeout } from "node:timers/promises";
const setTimeoutP = _setTimeout as any;
try { await setTimeoutP(0, "value"); } catch (e) { console.log("caught:", (e as Error).message); }So the trigger needs the namespace-member access form specifically (namespace.member(...) where namespace is from import * as). With the strict-namespace policy now in place, that form requires a recognized namespace path — and the partially-implemented ones (e.g. timers/promises, which compiles but has empty methods) appear to be the precise wedge.
Why file anyway
The bug pattern — catch-arm re-entry on await-of-undefined — is a runtime-correctness bug, not a parser issue. Even if v0.5.835's strict-import policy closed the specific trigger I observed, similar shapes are likely reachable via:
- npm packages running under
compilePackagesthat have runtimeobj.method(...)patterns wheremethodis undefined and the call isawait-ed. - User code that does
await someObj[dynamicKey](args)wheresomeObj[dynamicKey]is undefined. - Future Perry changes that re-enable namespace imports with empty methods.
The catch-arm should NEVER re-enter infinitely regardless of trigger. This bug could surface again as the codebase evolves.
Severity
When triggered, this hangs not just the user's program but downstream tooling that processes its output (the parity test runner spent 3 hours on this with bash's O(n²) line-decoding loop). Bounded timeout wrappers help, but a 5.7M-line stdout is a denial-of-service-level signal that something is fundamentally wrong with control flow.
Acceptance
Find another way to reach the same code path (perhaps via compilePackages + a method that resolves to undefined), demonstrate it doesn't re-enter the catch arm infinitely, or audit the await-throw handling in perry-codegen / perry-transform to ensure re-entry can't occur.
Related
- Filed during the parity test sweep (
test-files/test_parity_*.ts, infrastructure in commitsd0e84724and08c9004a). - Closely related to Namespace import for unrecognized node:* sub-paths resolves to
true(TAG_TRUE) #629 (namespace-import resolution) — the strict policy from v0.5.835 closes this issue's trigger, but doesn't necessarily fix the underlying control-flow bug.
Activity
fix(runtime): #712 defensive guard against runaway async step re-entr…
ci: #796 — linear-time normalize_output + per-test output cap (#825)
fix(runtime): #922 — circuit breaker for runaway `value is not a func…
fix(runtime): #922 — circuit breaker for runaway `value is not a func…
fix(gc): give the six unpruned rekeyed tables a death story (#8190-#8195
fix(gc): give the six unpruned rekeyed tables a death story (#8190-#8195
fix(gc): refuse a forwarding walk out of, or into, a non-object, and …