Repository navigation
HFIR adapter resource bounds: shared lowering, depth/size caps, run deadline (#169, #170) - #173
Conversation
howlcipher
left a comment
There was a problem hiding this comment.
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
-vrun exit 0:TestModelAdapterCoversArityTable: 386 round-trip; 126/159 covered, 33 exempt.TestProdFlipCriteriaLock.TestHFIRBenchmarkSolutionParity.- All 10
TestAuthority*. TestIssue165*(4),TestIssue166EnvGrantRefusesLoaderVars,TestIssue139,TestIssue143andTestIssue141*(5).
- The diff touches no files under
prod_flip_criteria_test.go,docs/reference/,benchmarks/orexperiments/. 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_DEEPin 95 ms, 28 MB. It was 4.3 GB before. - 1e15 ms sleep:
HFIR_DEADLINE_EXCEEDEDin 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_DEEPin 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_LARGEin under 3 ms and 0.1 MB. - A
listdiamond: the same numbers. Depth 14 runs and prints the nested list; 15 and above isTOO_LARGE. - A string-concat diamond is outlined as pure, so depth 20 compiles to 80 instructions.
callcan'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_LARGEin 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, grantedwrite_file;execandfetchdenied undertry_let;time_now,sleep 0, andtry_letover 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_idfield. - Adapter vs interpreter: 205/205 identical on the programs without
try_let, which-rundoesn't support. That covers stdout up to the first error, files and exit code. - A shared var-reading expression with
set xbetween 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 withHFIR_DEADLINE_EXCEEDED(3.0M instructions). It's the same when wrapped intry_let:CAUGHTis never printed.sleep 5000insidetry_letis refused immediately and is not caught. - Two 600 ms sleeps: the second is refused at 602 ms.
- Receipts record
deadline_ms: 1000,exit.status: errorand the typed code. - Scope: only programs returned by
CompileCandidateare affected, through the unexported in-memory mark. A non-adapterLowerToBytecodeloop ran 8.4 s withdeadline_ms: 0. Serialized artifacts and-run-bcdefaults 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.howlandexamples/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
TestAdapterDepthCapRejects4095ChainandTestAdapterDirectCandidateBounds. - Letting
try_letswallowHFIR_DEADLINE_EXCEEDEDfailsTestAdapterRunDeadlineAndSleep. - Removing the
validateTransportgate inCompileCandidatefailsTestAdapterDirectCandidateBounds.
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)
- Remove quadratic fragment copying, then lower the aggregate cap.
- A configurable, hard-capped adapter deadline before live network runs.
- Nits (a) to (d).
Closes #169. Closes #170.
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.