Skip to content

Test + I/O timeouts and the end-of-run engine wedge fix#3959

Open
aaronvg wants to merge 4 commits into
canaryfrom
aaron/test-and-io-timeouts
Open

Test + I/O timeouts and the end-of-run engine wedge fix#3959
aaronvg wants to merge 4 commits into
canaryfrom
aaron/test-and-io-timeouts

Conversation

@aaronvg

@aaronvg aaronvg commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Three related reliability fixes, extracted from the LLM-provider scenario work where they were found and battle-tested:

1. Per-test timeout in baml-cli test (default 5 min)

Every test body races a deadline inside the stdlib runner (testing/registry.baml::run_test). A hung test — stalled network read, deadlocked await — becomes a FAIL with test timed out after Nms instead of hanging the whole run. baml.future.race cancels the loser on settle (BEP-034), so a timed-out body is actively cancelled and a finished test's timer doesn't linger. Override with BAML_TEST_TIMEOUT_MS.

2. Default 5-minute deadline on http.send / http.fetch / http.fetch_sse

null timeout no longer means "no deadline": it resolves to 300s (pass an explicit Duration for longer — there is deliberately no unbounded spelling), and fetch_sse gets the same 300s total deadline in the host builder. A stalled socket now surfaces as baml.errors.Timeout instead of wedging the VM in a read that never yields — the one hang class BAML-level timeouts can't preempt.

3. Engine: settle spawned-child futures on engine error

A spawned child whose event loop returned Err(EngineError) (e.g. an awaiter parked on a future that settles to InternalError) was only logged in spawn_thread_inner, leaving the child's heap Future pending forever — its awaiter parked forever, cascading up to the end-of-run wait_for_outstanding_child_futures, so run_filtered → block_on never returned. Symptom in the wild: baml-cli test live sweeps completing all tests, all tokio workers parked, zero open sockets, process wedged for hours. Fix: settle_internal_error_if_pending + settle_spawn_engine_error on both error arms, so the error propagates deterministically up the await chain.

Deterministic regression test included (spawned_awaiter_of_internal_error_future_does_not_wedge: wedges ~11s without the fix, passes ~2s with). Verified additionally by repeated clean live-key test sweeps on the fixed build; the hung-test timeout behavior is probe-verified on this branch (1 passed, 1 failed — "test timed out after 2000ms" at a 2s override).

🤖 Generated with Claude Code


Note

High Risk
Changes default HTTP timeout semantics (breaking for callers that relied on unbounded waits) and touches async spawn/future settlement on error paths in the engine, which can affect shutdown and error propagation across concurrent runs.

Overview
Adds default 5-minute deadlines where work could hang indefinitely: http.fetch / http.send now map a null timeout to 300s (no unbounded spelling), fetch_sse gets the same total deadline in the native client, and run_test races each body against a timer (override via BAML_TEST_TIMEOUT_MS) using baml.future.race so losers are cancelled.

Fixes an engine shutdown wedge: when a spawned child’s event loop exits with EngineError or an invariant RootValue without settling its heap future, settle_internal_error_if_pending / settle_spawn_engine_error settle still-pending child futures so awaiters wake and errors propagate instead of parking forever at end-of-run quiescence.

Includes regression tests for nested-spawn / map+future.all topologies that previously hung call_function.

Reviewed by Cursor Bugbot for commit 2db4133. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features
    • HTTP requests and streaming fetches now default to a 5-minute timeout when none is provided.
    • Test runs now enforce per-test timeouts (default 300s, configurable via an environment variable) with clear timeout failure messaging.
  • Bug Fixes
    • Prevented engine failure scenarios from leaving futures in a “pending” state that could cause callers to wait indefinitely.
    • Improved child-task failure handling so spawned work resolves cleanly instead of wedging.

aaronvg and others added 3 commits July 9, 2026 11:05
Every test body now races a deadline inside the stdlib runner
(testing/registry.baml run_test): a hung test — stalled network read,
deadlocked await — becomes a FAIL with "test timed out after Nms" instead
of hanging the whole run. baml.future.race cancels the loser on settle
(BEP-034), so a timed-out body is actively cancelled and a finished test's
timer doesn't linger. Default 300000ms; override with BAML_TEST_TIMEOUT_MS.

Verified: a 60s-sleep test fails at a 2s override while the suite exits
promptly; full offline corpus unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
null timeout no longer means "no deadline": _timeout_nanos resolves it to
300s for send/fetch (deliberately no unbounded spelling), and fetch_sse gets
the same 300s total deadline in the host builder (connect + whole stream).
A stalled socket now surfaces as baml.errors.Timeout instead of wedging the
VM in a read that never yields. Verified: offline corpus green pre-kill;
live sweep 29/29 on the rebuilt binary earlier this morning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… wedge)

A spawned child whose event loop returned Err(EngineError) — e.g. an
awaiter parked on a future that settles to InternalError — was only LOGGED
in spawn_thread_inner, leaving the child's heap Future Pending forever. Its
awaiter then parked forever, cascading up to the B-650 end-of-run
wait_for_outstanding_child_futures: all work done, workers parked, zero
sockets, but run_filtered -> block_on never returns (the stuck-shell bug;
live-only because the leak needs the awaiter parked before the error fires).

Fix: settle_internal_error_if_pending on the FutureManager (tolerant of
racing cancels) + settle_spawn_engine_error called from BOTH the Err and
invariant-violating Ok(RootValue) arms of spawn_thread_inner, so the error
propagates deterministically up the await chain instead of leaking.

Regression test: spawned_awaiter_of_internal_error_future_does_not_wedge
(deterministic, offline; wedges ~11s without the fix, passes ~2s with).
Verified additionally by 5+ clean live sweeps on the patched build. The
separate type-soundness hole used during diagnosis is filed as B-797.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Jul 9, 2026 6:46pm
promptfiddle Ready Ready Preview, Comment Jul 9, 2026 6:46pm
promptfiddle2 Ready Ready Preview, Comment Jul 9, 2026 6:46pm

Request Review

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

HTTP requests and SSE calls now default to 5 minutes, spawned engine errors settle pending child futures instead of leaving them stuck, and test execution now races the body against a configurable timeout.

Changes

HTTP Default Timeout

Layer / File(s) Summary
BAML http.baml timeout default and docs
baml_language/crates/baml_builtins2/baml_std/baml/ns_http/http.baml
_timeout_nanos maps null to 300000000000n instead of 0n, with updated fetch/send doc comments.
Native fetch_sse default timeout
baml_language/crates/sys_native/src/io_impls.rs
fetch_sse applies a default 5-minute reqwest builder timeout before .send().await.

Engine Spawned Future Settlement

Layer / File(s) Summary
FutureManagerGuard settlement helper
baml_language/crates/bex_engine/src/future.rs
settle_internal_error_if_pending settles a future to InternalError only when it is still Pending, and no-ops if the entry is missing.
spawn_thread_inner settlement wiring
baml_language/crates/bex_engine/src/lib.rs
settle_spawn_engine_error is added and called from spawned-thread terminal arms so pending child futures are settled and woken on engine failure or root-value termination.
Regression tests for wedge fix
baml_language/crates/bex_engine/tests/host_value_callable.rs
Two new tests reproduce spawned-future bridge failures and assert the engine call resolves instead of wedging.

Test Runner Timeout

Layer / File(s) Summary
run_test race-based timeout
baml_language/crates/baml_builtins2/baml_std/testing/registry.baml
base_run spawns work and timer futures, races them, and uses BAML_TEST_TIMEOUT_MS with a 300000ms default.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • BoundaryML/baml#3635: Both PRs modify spawn_thread_inner behavior for handling spawned child futures during thread termination.
  • BoundaryML/baml#3771: Both PRs change _timeout_nanos conversion logic and related HTTP fetch/send timeout plumbing.
  • BoundaryML/baml#3925: Both PRs modify bex_engine/src/future.rs and src/lib.rs to address hangs from Pending futures at shutdown/end-of-run.

Poem

A rabbit set a timer down,
five minutes flat, no need to frown 🐇⏱️
No more sockets stuck and stalled,
no more futures left appalled,
hop, hop — the engine wakes at last,
pending dreams now settled fast!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the three main changes: test timeouts, HTTP I/O timeouts, and the engine wedge fix.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch aaron/test-and-io-timeouts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@baml_language/crates/sys_native/src/io_impls.rs`:
- Around line 2213-2218: Remove the hardcoded 300-second RequestBuilder::timeout
in fetch_sse within io_impls.rs, since it applies to the full request and can
terminate active SSE streams mid-flight. Replace it with an idle/read timeout
approach that only trips when the stream stalls, or thread a configurable
SSE-specific timeout through fetch_sse so callers can control long-lived
streams.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cc1d0e67-3f94-4456-8600-34cca28116df

📥 Commits

Reviewing files that changed from the base of the PR and between 5bbf6b5 and 303ae9f.

📒 Files selected for processing (6)
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_http/http.baml
  • baml_language/crates/baml_builtins2/baml_std/testing/registry.baml
  • baml_language/crates/bex_engine/src/future.rs
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/crates/bex_engine/tests/host_value_callable.rs
  • baml_language/crates/sys_native/src/io_impls.rs

Comment on lines +2213 to +2218
// Default 5-minute total deadline (connect + the whole stream): a
// stalled SSE socket must surface as a Timeout instead of wedging
// the VM in a read that never yields. Mirrors `_send`/`_fetch`'s
// stdlib-level default. Legitimately longer-lived streams need a
// dedicated timeout parameter on `fetch_sse` (not yet plumbed).
builder = builder.timeout(std::time::Duration::from_secs(300));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

reqwest RequestBuilder timeout applies to streamed response body bytes_stream total deadline

💡 Result:

In reqwest, the timeout set on a RequestBuilder (or the global ClientBuilder) acts as a total deadline for the entire request, from the start of the connection until the response body has been completely consumed [1][2][3]. Key points regarding the timeout and streamed response bodies: 1. Total Deadline: When you configure a timeout (e.g., via RequestBuilder::timeout), it applies to the entire duration of the request and response lifecycle [1][3]. If the response body is being streamed using bytes_stream, the total time taken to stream all bytes must be within this deadline [4][2][5]. If the stream is not finished by this time, the request will error with a timeout [4][5]. 2. Alternative for Stalled Streams: Because the standard timeout is a total deadline, it is often unsuitable for monitoring long-lived or large streams where the total duration is unknown or naturally exceeds a reasonable timeout. For this use case, reqwest provides a separate read_timeout option [4][3]. 3. Read Timeout: Configured via ClientBuilder::read_timeout, this timeout applies to each individual read operation on the stream [4][3]. It resets after each successful read, making it effective for detecting stalled connections without requiring knowledge of the total download size or duration [4][3]. If both a total timeout and a read timeout are configured, reqwest applies both: the operation must respect the individual read limit and must complete in its entirety before the total deadline is reached [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant implementation around fetch_sse.
FILE="baml_language/crates/sys_native/src/io_impls.rs"

echo "== File size =="
wc -l "$FILE"

echo
echo "== Relevant lines around 2213-2218 =="
sed -n '2185,2245p' "$FILE" | cat -n

echo
echo "== Search for fetch_sse and timeout-related logic in this file =="
rg -n "fetch_sse|timeout\\(|read_timeout|bytes_stream|sse" "$FILE"

Repository: BoundaryML/baml

Length of output: 4746


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="baml_language/crates/sys_native/src/io_impls.rs"

echo "== apply_http_timeout helper =="
sed -n '1850,1888p' "$FILE" | cat -n

echo
echo "== fetch_sse stream consumption =="
sed -n '2268,2315p' "$FILE" | cat -n

echo
echo "== nearby SSE-related functions =="
sed -n '2315,2350p' "$FILE" | cat -n

Repository: BoundaryML/baml

Length of output: 5547


Avoid a hard 5-minute deadline on SSE streams RequestBuilder::timeout covers the whole request, so response.bytes_stream() can be cut off mid-stream after 300s even if data keeps arriving. If the goal is to fail stalled sockets, use an idle/read timeout or make the SSE timeout configurable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@baml_language/crates/sys_native/src/io_impls.rs` around lines 2213 - 2218,
Remove the hardcoded 300-second RequestBuilder::timeout in fetch_sse within
io_impls.rs, since it applies to the full request and can terminate active SSE
streams mid-flight. Replace it with an idle/read timeout approach that only
trips when the stream stalls, or thread a configurable SSE-specific timeout
through fetch_sse so callers can control long-lived streams.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Binary size checks passed

7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
baml-cli Linux 🔒 22.0 MB 9.4 MB file 21.5 MB +433.4 KB (+2.0%) OK
packed-program Linux 🔒 15.9 MB 6.7 MB file 15.6 MB +292.2 KB (+1.9%) OK
baml-cli macOS 🔒 16.9 MB 8.2 MB file 16.6 MB +348.0 KB (+2.1%) OK
packed-program macOS 🔒 12.3 MB 5.9 MB file 12.1 MB +232.0 KB (+1.9%) OK
baml-cli Windows 🔒 18.5 MB 8.4 MB file 18.1 MB +358.4 KB (+2.0%) OK
packed-program Windows 🔒 13.2 MB 5.9 MB file 13.0 MB +233.8 KB (+1.8%) OK
bridge_wasm WASM 14.7 MB 🔒 4.2 MB gzip 4.1 MB +115.4 KB (+2.8%) OK

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.


Generated by cargo size-gate · workflow run

…ak fix

Pins the second real-world casualty of the leaked-pending-future bug: the
scenario-15 "concurrent guards" shape — children spawned inside a .map
closure, joined with baml.future.all under a spawned parent with a linked
CancelToken, one child hitting an uncatchable engine error while siblings
are parked. Documented at the time as a VM deadlock (parks at 0% CPU); A/B
proven here to be the same root cause: with the settle fix reverted this
test wedges (Elapsed at the 10s guard), with it it resolves in ~4s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aaronvg

aaronvg commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Added a second regression test: the spawn-in-map + future.all + CancelToken guard topology (the scenario-15 'VM deadlock') is A/B-proven to be the same leaked-pending-future root cause — wedges with the fix reverted, resolves in ~4s with it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant