Skip to content

toThrow doesn't catch exceptions thrown from called functions inside the closure (only direct throws) #1

Description

@JairusSW

toThrow doesn't catch exceptions thrown from called functions inside the closure (only direct throws)

Summary

expect((): void => { ... }).toThrow() catches a direct throw/abort written inside the closure, but not an exception raised by a function the closure calls (especially across a module boundary). The callee's throw/abort is left un-rewritten, so at runtime it hits the real abort and fatally traps the module instead of being caught.

This makes toThrow unusable for testing that a library rejects bad input, since real libraries reject deep in called code (e.g. a parser's JSON.parse(...) → internal validator → throw/abort), not with a literal throw in the test closure.

Reproduction

Two files compiled with as-test + --enable try-as (WASI target):

// shapes.ts
export function deep(): void { throw new Error("boom"); }
export function mid(): void { deep(); }
export function abortDeep(): void { abort("boom"); }
export function abortMid(): void { abortDeep(); }
// repro.spec.ts
import { mid, abortMid } from "./shapes";
import { describe, expect } from "as-test";

describe("toThrow", () => {
  // ✅ PASSES — direct throw in the closure is rewritten
  expect((): void => { throw new Error("boom"); }).toThrow();

  // ❌ FAILS (fatal trap: "abort: boom", uncaught) — cross-module throw chain
  expect((): void => { mid(); }).toThrow();

  // ❌ FAILS (fatal trap) — cross-module abort chain
  expect((): void => { abortMid(); }).toThrow();
});

Expected: all three are caught (toThrow passes).
Actual: only the direct-throw case is caught; the two cross-module cases trap fatally (abort: boom in shapes.ts(...)~lib/wasi_internal/wasi_abort), aborting the test process.

A single hop is enough to trigger it (closure → mid → deep); it is not specific to deep call graphs.

Root cause

In transform/src/passes/source.ts, the call-expression gate only follows calls when a tracked function or active try is in scope:

// visitCallExpression (~line 237)
if (this.state != "postprocess" && !Globals.lastFn && !Globals.lastTry)
  return super.visitCallExpression(node, ref);

An anonymous arrow (the toThrow callback) intentionally sets Globals.parentFn but not Globals.lastFn (see the arrow handling at ~line 147–179, which leaves lastFn unset for unnamed arrows). So inside the closure, lastFn and lastTry are both null, and this gate returns early for every callmid() is never resolved/followed, so mid/deep's throw/abort are never rewritten to bump __ExceptionState.Failures. At runtime they hit the real abort.

Notably the throw-statement gate just below already allows parentFn:

// visitThrowStatement (~line 328)
if (this.state != "postprocess" && !Globals.lastTry && !Globals.parentFn)
  return super.visitThrowStatement(node, ref);

— which is why a direct throw in the closure is rewritten but a called function's throw is not. The call gate and the throw gate are inconsistent.

Why the obvious one-line fix isn't enough

Adding && !Globals.parentFn to the call gate (line 237) does make closure-body calls get followed — but it then also follows the enclosing expect(...).toThrow() chain, marks expect exception-capable, and renames it to __try_expect, which is never generated:

ERROR TS2304: Cannot find name '__try_expect'.

So a correct fix needs to:

  1. Follow calls inside the closure body (the gate change), but
  2. keep the matcher chain / external (node_modules) calls opaqueexpect/toThrow must not be followed or renamed (they invoke the closure and consume the failure via the Failures delta themselves), and
  3. resolve the harder call-target forms real libraries use — namespace methods (JSON.parse), generics (<T>), and instance/struct method dispatch (obj.method(...)) — which findFn/resolveMethodRef don't currently fully trace into.

Impact

Any project using expect(() => lib.doThing(bad)).toThrow() where the rejection happens inside the library (rather than as a literal throw in the test closure) cannot test rejection — the test process traps. Discovered while wiring an RFC 8259 conformance suite for json-as (which rejects malformed JSON via throw/abort deep in its deserializers).

Environment

  • try-as: 1.1.0
  • Harness: as-test with --enable try-as (AS_TEST_TRY_AS=1, AS_TEST_WASI=1), WASI target via @assemblyscript/wasi-shim, run under wasmtime.
  • Modes: reproduces in all (naive/swar/simd) json-as builds.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions