Skip to content

HFIR adapter resource bounds: shared lowering, depth/size caps, run deadline (#169, #170) - #173

Merged
howlcipher merged 1 commit into
mainfrom
hf-blockers
Oct 13, 2026
Merged

howlcipher merged 1 commit into
mainfrom
hf-blockers

Conversation

@howlcipher

Copy link
Copy Markdown
Owner

Closes #169. Closes #170.

  • Shared closed pure scalar subexpressions lower once (effects, var reads and allocating exprs keep their sites/order).
  • Fail-closed caps: 2048 dependency levels, 65536 expanded nodes, 4194304 aggregate fragment nodes -> HFIR_TRANSPORT_TOO_DEEP / HFIR_TRANSPORT_TOO_LARGE (also for directly constructed Candidates).
  • Adapter runs get a 1s wall-clock budget; sleep beyond remaining budget is refused immediately with HFIR_DEADLINE_EXCEEDED (uncatchable by try_let); receipts record budget + typed failure.

Tests (internal/hfir/adapter_blockers_test.go): TestAdapterSharedDepth22Bounded, TestAdapterDepthCapRejects4095Chain, TestAdapterExpansionCap, TestAdapterAggregateFragmentCap, TestAdapterDirectCandidateBounds, TestAdapterRunDeadlineAndSleep, TestAdapterSharedEffectsKeepOrder. 10 mutations (sharing, depth cap, size cap, aggregate cap, direct gate, adapter deadline, loop deadline, sleep budget, uncatchable deadline, receipt budget) each fail a test.

No production flip; #90 stays Partial; pins/freeze/lock/arity tests unchanged; breaking changes in change_log.md.

@howlcipher howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of #173 at 6f9115afeefc48beee7f6009f4de55345f259250 (base 1bda2f1)

Verdict: merge-ready. It closes #169 and #170. The lock, criteria doc, pins and freeze are untouched, so no Owner gate applies. I found no blockers. Three follow-ups are listed below.

I used a fresh worktree plus a separate probe worktree, both removed afterwards. Port 8080 was free and I started no servers.

Gates (real exit codes)

  • gofmt -l . is empty. go vet ./... exit 0. go test ./... exit 0 (75 packages ok).
  • Focused -v run exit 0:
    • TestModelAdapterCoversArityTable: 386 round-trip; 126/159 covered, 33 exempt.
    • TestProdFlipCriteriaLock.
    • TestHFIRBenchmarkSolutionParity.
    • All 10 TestAuthority*.
    • TestIssue165* (4), TestIssue166EnvGrantRefusesLoaderVars, TestIssue139, TestIssue143 and TestIssue141* (5).
  • The diff touches no files under prod_flip_criteria_test.go, docs/reference/, benchmarks/ or experiments/. The lock is unchanged, there's no flip and #90 stays Partial.

1. Blowup

All measurements are decode+compile under ulimit -v 8 GB, with no OOM anywhere.

#167 repros:

  • Pure diamond depth 20: 1.6 ms, 0.5 MB alloc, 80 instructions.
  • Pure diamond depth 22: 1.4 ms, 0.6 MB, 88 instructions. It was OOM before.
  • 4095-node chain: HFIR_TRANSPORT_TOO_DEEP in 95 ms, 28 MB. It was 4.3 GB before.
  • 1e15 ms sleep: HFIR_DEADLINE_EXCEEDED in 75 µs.

Pure diamonds at the cap:

  • Depth 1000: 200 ms, 29 MB. Depth 2000: 264 ms, 58 MB.
  • Depths 2046, 2047, 2048 and 2050 are rejected TOO_DEEP in about 100 ms and 15 MB. The cap counts from the entry node, so it trips about 2 levels below the nominal 2048.
  • At runtime, outlined sharing still executes 2^d calls. The instruction limit stops that, and depth 2000 stops on call depth limit exceeded (max 1000) because the outlined calls nest. That's fine, but see nit (a).

Shared nodes that aren't closed pure scalar (they keep their sites):

  • A diamond with a symbol leaf: depth 14 is accepted (32,768 instructions, 117 ms, 51 MB alloc, 83 MB heap). Depth 15 and above is TOO_LARGE in under 3 ms and 0.1 MB.
  • A list diamond: the same numbers. Depth 14 runs and prints the nested list; 15 and above is TOO_LARGE.
  • A string-concat diamond is outlined as pure, so depth 20 compiles to 80 instructions.
  • call can't be shared, because the effect-sharing check rejects it.

Worst case under the caps is a quadratic linear chain. It is binary(prev, symbol) with 4003 nodes, depth 2001 and aggregate just under 4,194,304:

  • 1.73 s, 1.14 GB allocated, about 1.04 GB peak heap.
  • At 1000 deep it's 0.5 s and 307 MB. At 1400 it's 1.1 s and 573 MB.
  • Length 2045 is rejected TOO_LARGE in 114 ms and 23 MB.

So the caps do bound memory (no OOM), but about 1 GB is reachable from a 480 KB proposal. The aggregate cap can't just be lowered: tests/test_let_chain.howl itself needs an aggregate of 2,259,006 (depth 1503). Follow-up: stop copying expanded child fragments in lowering (emit by reference or append into one buffer), then drop the aggregate cap to about 1M.

2. Effect order (differential)

600 generated programs, seed-deterministic. Each starts with let x, and its statements mix:

  • print E E, set x, granted write_file;
  • exec and fetch denied under try_let;
  • time_now, sleep 0, and try_let over arithmetic.

Expressions draw from a pool of repeated subexpressions: closed (+ 2 3), (to_int "12"), the overflowing (* 9223372036854775807 2), and var-reading (* x 2) and (/ 10 (- x x)).

The adapter path is LowerAST, then hash-consing every effect-free subtree into a shared DAG (531k merges, 1,797 __hfir_shared_* functions emitted), then CompileCandidate and RunBytecodeWithReceipt.

  • Adapter vs AST bytecode: 600/600 identical in stdout, written files, exit code and receipt effects (order and decisions). I normalized the temp dir and the HFIR-only node_id field.
  • Adapter vs interpreter: 205/205 identical on the programs without try_let, which -run doesn't support. That covers stdout up to the first error, files and exit code.
  • A shared var-reading expression with set x between its uses gives the same values. Errors in shared expressions (division by zero, integer overflow, to_int) fire at the same statement, once, with identical prior output and effects.

3. Deadline

Runs used MaxInstructions = 2^40, so the deadline, not the instruction limit, is what stops them.

  • Busy while true: stops at 1.01 s with HFIR_DEADLINE_EXCEEDED (3.0M instructions). It's the same when wrapped in try_let: CAUGHT is never printed. sleep 5000 inside try_let is refused immediately and is not caught.
  • Two 600 ms sleeps: the second is refused at 602 ms.
  • Receipts record deadline_ms: 1000, exit.status: error and the typed code.
  • Scope: only programs returned by CompileCandidate are affected, through the unexported in-memory mark. A non-adapter LowerToBytecode loop ran 8.4 s with deadline_ms: 0. Serialized artifacts and -run-bc defaults are unchanged.
  • Configurability: a caller can shorten the deadline but not extend it (a caller asking for 10 s still got 1 s). There's no flag.
  • Timing:
    • 268 benchmark solutions on the HFIR path, wall time including process start and compile: p50 14 ms, p99 73 ms, max 103 ms.
    • 393 corpus programs run in-process through the adapter with no grants: p50 69 µs, p99 2.6 ms. The only programs over budget are tests/test_timeout.howl and examples/poll_cli (HFIR_DEADLINE_EXCEEDED), which sleep or poll by design.
  • Recommendation: keep 1 s as the default; the max is about 10x under it. Add a runner-owned option (for example -adapter-deadline, hard-capped at about 30 s) before any live run that makes real network fetches or polls. A single real HTTP round trip can use most of 1 s.

4. Mutation spot-checks

I applied each mutation locally, ran the TestAdapter* tests, then reverted.

  • Disabling the depth rejection fails TestAdapterDepthCapRejects4095Chain and TestAdapterDirectCandidateBounds.
  • Letting try_let swallow HFIR_DEADLINE_EXCEEDED fails TestAdapterRunDeadlineAndSleep.
  • Removing the validateTransport gate in CompileCandidate fails TestAdapterDirectCandidateBounds.

All three exited 1.

Nits

(a) Outlined shared expressions use call depth, so a deeply nested shared pure expression can fail on the call-depth limit where the inline form would just run. Consider inlining shared nodes below some size, or not counting outlined calls.
(b) The depth error says "exceeds 2048 dependency levels", but it trips at 2046 or more counting from program. Align the message or the count.
(c) The HFIR_TRANSPORT_TOO_LARGE message always says "65536 nodes", even when the aggregate 4M cap is what tripped. Name the cap that fired.
(d) try_let still swallows the generic LIMIT_EXCEEDED (instruction and memory limits) for non-adapter programs. That's pre-existing, but worth the same treatment.

Follow-ups (not blocking)

  1. Remove quadratic fragment copying, then lower the aggregate cap.
  2. A configurable, hard-capped adapter deadline before live network runs.
  3. Nits (a) to (d).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant