Repository navigation
feat(spawn): spawn options — with baml.spawn.options(...), CancelToken, detach, TaskGroup (BEP-034) - #3596
feat(spawn): spawn options — with baml.spawn.options(...), CancelToken, detach, TaskGroup (BEP-034)#3596antoniosarosi wants to merge 4 commits into
with baml.spawn.options(...), CancelToken, detach, TaskGroup (BEP-034)#3596Conversation
…+ CancelToken (BEP-034) First slice of BEP-034 "spawn options": the `with` clause on `spawn`, a new `baml.spawn` namespace with a BAML-surface `CancelToken` (`new`/`any`/`cancel`/`is_cancelled`), and `baml.spawn.options(group?, cancel?)` producing an opaque `SpawnConfig`. A user cancel token links into a spawned task's effective token via a self-terminating watcher; firing it (or an `any`-composed token) cancels the child, settles its future `Cancelled`, and `await` re-throws it (catchable). - Surface: ns_spawn/spawn.baml (CancelToken, SpawnConfig, TaskGroup stub, options) - Runtime: SpawnConfigData + UnscheduledFuture.config; engine reads the config's cancel token and links a watcher in spawn_thread; GC traces the config ptr. - Compiler: parse `with expr (, expr)*`; Expr::Spawn.with_exprs; thread an optional config operand through TIR/MIR/emit and the Spawn bytecode (now pops [closure, name, config]). - `spawn`/`await` are now valid as path segments after `.` so `baml.spawn.X` parses (mirrors the existing KW_CLIENT precedent). Deferred: `detach` (needs an OmittedArg-for-primitive codegen fix) and strict `with`-clause validation. Tests: 4 engine cancellation tests + ns_cancel_token fixture; snapshots regenerated.
Adds `detach = true` to `baml.spawn.options(...)` and the codegen fix it depends on. - Codegen: `Value::is_omitted()`; the generated optional-arg extraction now treats an omitted argument (the `OmittedArg` sentinel) the same as `null` (→ `None`), so optional builtin params — including primitives like `bool?` — accept being left out. Unblocks `options(group?, cancel?, detach?)`. - detach: `SpawnConfigData.detach`; a detached spawn gets a fresh effective cancel token (`CancellationToken::new()` instead of `parent.child_token()`), derived before the user-token watcher links, so the parent cascade / unhandled throw does not reach it. An explicit `cancel` token still applies. - Root-error routing: `BexThread.root_pending_errors` is propagated unchanged down the spawn tree; a detached spawn routes its unhandled fire-and-forget error to the root task's queue (surfaces at the root's next `await`) rather than the spawner's. Tests: 2 detach engine tests + a detach BAML fixture test; snapshots regenerated.
Adds `baml.spawn.TaskGroup` and `spawn ... with baml.spawn.options(group = g)`. A group caps how many spawns referencing it run concurrently; excess spawns queue FIFO and start as earlier ones settle (the `spawn` still returns its `Future` immediately). - Surface: `TaskGroup.new(limit, name?)`, `cancel(pending?, active?) -> int`, `set_limit`, `limit`, `name`, `active_count`, `queued_count`; `options(group=)`. - Runtime: `bex_vm_types::TaskGroupInner` admission state (std `Mutex` + a per-waiter `oneshot`). Members register SYNCHRONOUSLY at spawn time (so `cancel()`/`active_count()`/`queued_count()` right after `spawn` are exact); the body task parks on a `TaskGroupTicket` for a slot — without the heap permit, so a queued task doesn't block GC — and releases on settle, waking the next FIFO waiter. The ticket's select is cancel-biased so a member cancelled via `group.cancel()` never runs. `set_limit` grows (drains waiters) / shrinks (no preempt) / `0` (pauses). - Engine: `SpawnConfigData.group` threaded through `spawn_thread`; admission runs before `run_thread_event_loop`. Verified spawns actually parallelize for real IO (execution-only timing): limit=3 runs three 200ms sleeps in ~200ms; limit=1 serializes them (~600ms). Tests: 7 engine tests (deterministic counts/cancel/set_limit + parallel/serial timing) + 2 admission unit tests + a TaskGroup BAML fixture; snapshots regenerated.
…tive (BEP-034) Completes the spawn-options core. - TIR now requires each `spawn ... with` expression to produce a `baml.spawn.SpawnConfig` — i.e. a `baml.spawn.options(...)` call, or a variable bound to one (type-based, so it's forward-compatible) — and rejects more than one config. New `TirTypeError::SpawnWithMustBeSpawnConfig` / `SpawnWithTooManyConfigs`, mapped through the LSP diagnostic id table. - Fix `baml_tests/tests/spawn_parallel.rs`: it used `sys_ops` whose `sleep` is `Unsupported`, so its "parallel sleeps" were no-ops caught by a `catch` — a false positive. It now uses `sys_native` (real `tokio::time::sleep`) and genuinely verifies three concurrent 200ms sleeps finish in ~200ms. Tests: a negative `spawn with <non-options>` compile-rejection test; the fixed spawn_parallel now exercises real concurrency.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR implements BEP-034 spawn configuration, extending the spawn expression with an optional ChangesSpawn Options Configuration
🎯 4 (Complex) | ⏱️ ~45 minutes Possibly Related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
baml_language/crates/baml_compiler_parser/src/parser.rs (1)
5089-5112:⚠️ Potential issue | 🟠 Major | ⚡ Quick winThis widens dotted keywords only in expression paths, not in type paths.
parse_path_or_ident()now accepts.spawn/.await, butparse_type_primary()still requiresTokenKind::Wordafter.. That means types introduced by this PR, likebaml.spawn.CancelTokenorbaml.spawn.TaskGroup, will still fail in annotations even though the expression form parses.🤖 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/baml_compiler_parser/src/parser.rs` around lines 5089 - 5112, parse_type_primary currently only accepts TokenKind::Word after a dot, so type paths like baml.spawn.CancelToken fail; update parse_type_primary to mirror the same segment logic used in parse_path_or_ident (or extract the segment predicate into a shared helper) to accept TokenKind::Client, TokenKind::Spawn, and TokenKind::Await as valid path segments after a Dot, ensuring type path parsing (e.g. in parse_type_primary) uses the same check as the segment closure used when building SyntaxKind::PATH_EXPR.
🧹 Nitpick comments (4)
baml_language/crates/baml_compiler_parser/src/parser.rs (1)
4515-4551: ⚡ Quick winAdd parser unit coverage for the new spawn grammar.
Please add unit tests in this file for at least: a valid
spawn ... with baml.spawn.options(...) { ... }, trailing-comma recovery before the body brace, missing-expression recovery afterwith, and dotted keyword segments likebaml.spawn.CancelToken. These are parser-local behaviors and are best locked down here. As per coding guidelines:**/*.rs: Prefer writing Rust unit tests over integration tests where possible;Always run cargo test --lib if you changed any Rust code.Also applies to: 5089-5112
🤖 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/baml_compiler_parser/src/parser.rs` around lines 4515 - 4551, Add unit tests covering the new spawn grammar in this parser module: create tests that exercise parse_spawn_expr including a valid "spawn ... with baml.spawn.options(...) { ... }" case, a case with a trailing comma before the body brace to verify recovery, a missing-expression-after-`with` recovery case, and dotted keyword segments like "baml.spawn.CancelToken" (these tests should invoke the parser on snippet strings and assert the produced syntax kind is SPAWN_EXPR and that error/recovery nodes are present as expected). Place tests in the same Rust file as parse_spawn_expr (using #[cfg(test)] mod tests) and ensure they call the parser entry used by parse_spawn_expr (so the code paths using suppress_object_literal_depth, bump_contextual_with, and parse_expr_bp are hit); run cargo test --lib to verify.baml_language/crates/baml_builtins2/baml_std/baml/ns_spawn/spawn.baml (1)
104-112: 💤 Low valueStale doc comment: TaskGroup is fully implemented in this PR.
The comment states
groupis "accepted for forward compatibility and wired in a follow-up," but according to the PR description, TaskGroup rate limiting is fully implemented in this changeset (Layer 8 implements the FIFO rate limiter, Layer 9 wires the VM builtins). Consider updating the comment to reflect the actual implementation status./// Assemble a `SpawnConfig` for a `spawn ... with` clause. `cancel` links a /// cancel token into the spawn's effective token; `detach = true` opts the /// spawn out of the parent→child cancel cascade and routes its unhandled -/// errors to the root task instead of the spawner. (`group` is accepted for -/// forward compatibility and wired in a follow-up.) +/// errors to the root task instead of the spawner. `group` registers the spawn +/// with a `TaskGroup` for FIFO rate limiting.🤖 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/baml_builtins2/baml_std/baml/ns_spawn/spawn.baml` around lines 104 - 112, The doc comment on the options function is stale about TaskGroup not yet wired; update the comment for function options(group: TaskGroup? = null, cancel: CancelToken? = null, detach: bool? = null) -> SpawnConfig to reflect that TaskGroup is implemented and wired in this PR (remove the "forward compatibility" note and state that group support and rate limiting are active, and optionally mention FIFO rate limiter/Layers 8–9 as implemented); keep the rest of the description about cancel and detach intact.baml_language/crates/bex_heap/src/gc.rs (1)
1904-1908: ⚡ Quick winAdd one GC test that uses
UnscheduledFuture.config = Some(...).Current updated tests only use
config: None, so they don’t directly prove tracing/fixup of non-null config pointers.Proposed test adjustment
@@ let closure = tlab.alloc_string("closure-stand-in".to_string()); let name = tlab.alloc_string("spawn-name".to_string()); + let config_obj = tlab.alloc(Object::RustData(Arc::new( + bex_vm_types::types::SpawnConfigData::default(), + ))); let future_ptr = tlab.alloc(Object::UnscheduledFuture(UnscheduledFuture { closure, name: Some(name), - config: None, + config: Some(config_obj), })); @@ - // future + closure + name - assert_eq!(stats.live_count, 3); + // future + closure + name + config + assert_eq!(stats.live_count, 4);Also applies to: 2421-2425
🤖 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/bex_heap/src/gc.rs` around lines 1904 - 1908, The tests currently allocate UnscheduledFuture instances with UnscheduledFuture.config = None, so they don't exercise pointer-tracing/fixup for non-null config fields; update or add a GC test that allocates via tlab.alloc an Object::UnscheduledFuture where UnscheduledFuture.config = Some(some_heap_object) (use the same helper objects used elsewhere in the tests), hold a reference to that config across a GC, trigger tracing/fixup, and assert the config pointer is preserved/updated after collection; update both occurrences around the future_ptr allocation (the block using tlab.alloc for future_ptr and the similar block at the later 2421-2425 region) to include a non-None config variant so tracing code for UnscheduledFuture.config is covered.baml_language/crates/bex_vm/Cargo.toml (1)
35-45: Tokioselect!on wasm32 is covered byfeatures = ["macros"](nortneeded); native still usestokio::spawn. Inbex_vm/src/package_baml/spawn.rs, the shared watcher usestokio::select!, which only depends on themacrosfeature, while the wasm32 watcher is spawned viawasm_bindgen_futures::spawn_local(notokioruntime usage). The remaining nit is redundancy: the native target adds["rt", "macros"]even thoughmacrosis already enabled in the basetokiodependency.🤖 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/bex_vm/Cargo.toml` around lines 35 - 45, The Cargo.toml currently enables tokio macros globally and also repeats "macros" in the native target-specific tokio dependency; remove the redundant "macros" from the native target entry so that the base tokio = { workspace = true, features = ["macros"] } stays as-is and the target.'cfg(not(target_arch = "wasm32"))'.dependencies tokio entry becomes tokio = { workspace = true, features = ["rt"] } (leave wasm-bindgen-futures and the wasm target block unchanged).
🤖 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/baml_compiler_parser/src/parser.rs`:
- Around line 4538-4551: The parser currently always calls p.parse_expr_bp(1)
after bump_contextual_with(), which lets a following `{` be consumed as the
config expression and eat the spawn body; instead, guard the config parse so it
does not run when the next token is a left brace: after p.bump_contextual_with()
check p.at(TokenKind::LBrace) and, if true, report a missing/empty config (or
recover) and skip parsing the expression so the body remains intact; only adjust
p.suppress_object_literal_depth and call p.parse_expr_bp(1) when the next token
is not TokenKind::LBrace, keeping the rest of the while p.eat(TokenKind::Comma)
logic unchanged to preserve comma handling for real config expressions in
SPAWN_EXPR.
In `@baml_language/crates/baml_compiler2_tir/src/builder.rs`:
- Around line 3226-3239: The check for SpawnConfig uses cfg_ty directly and
fails for type aliases; before computing is_spawn_config you should expand type
aliases on cfg_ty (e.g., resolve through whatever alias-expansion helper or call
you have) and then match the expanded type against Ty::Class to detect
baml.spawn.SpawnConfig; update the code that computes is_spawn_config (and keep
unresolved logic) to use the expanded type so
report_simple(TirTypeError::SpawnWithMustBeSpawnConfig, *with_id) is only
emitted when the underlying (expanded) type is not SpawnConfig.
In `@baml_language/crates/bex_engine/tests/cancellation.rs`:
- Around line 933-936: The test mutates the global panic hook
(take_hook/set_hook) which can interfere with other concurrent tokio tests;
remove the hook mutation and instead rely on std::panic::catch_unwind around the
call to compile_for_engine(bad) (or run the panic-producing call in an isolated
subprocess if you must suppress diagnostics), so do not call
std::panic::take_hook or std::panic::set_hook in cancellation.rs and keep only
the catch_unwind wrapping compile_for_engine to observe the panic payload.
In `@baml_language/crates/bex_vm_types/src/task_group.rs`:
- Around line 68-78: TaskGroupTicket currently leaks group state when dropped;
implement ownership cleanup by adding a Drop for TaskGroupTicket that
unregisters the ticket from TaskGroupInner unless the ticket has already been
consumed by a successful acquire(); modify TaskGroupTicket.acquire() to
atomically mark the ticket as consumed (e.g., set rx to None or flip a
bool/AtomicBool) and return the permit/guard that becomes responsible for
decrementing active on its Drop, and add an internal
TaskGroupInner::unregister(id) (or call the existing decrement path) used by
Drop to decrement active/cancel queued state for tickets that were never
acquired; ensure both the parked (rx/ cancel) and immediately-admitted paths set
the consumed flag so Drop does not double-unregister.
- Around line 156-163: set_limit currently calls Self::promote(&mut st) which
only grants one waiter because promote returns after the first success; update
promote (or add a new loop in set_limit) to repeatedly promote waiters until
either st.limit == st.active or no more waiters remain so widening the cap from
1 to N fills all spare slots immediately; modify the logic in promote
(referencing the promote function) to not break after the first grant (or call
promote in a loop from set_limit) and ensure it respects paused (limit==0)
behavior. Also add a unit test that constructs a TaskGroup, sets limit=1, parks
enough waiters to exceed 1, then calls set_limit to a value > active and asserts
that the group’s active count increases to the new limit (and all eligible
waiters are admitted) right after set_limit returns.
In `@baml_language/crates/bex_vm/src/package_baml/spawn.rs`:
- Around line 84-109: The watchers can leak because
CancellationToken::cancelled() doesn't fire when the last clone is dropped; fix
by creating a CancellationTokenDropGuard (via CancellationToken::drop_guard())
for the composite token and store that guard with the returned VM token so
dropping the VM handle triggers cancel(); specifically, in any() create let
guard = composite.drop_guard() (or CancellationToken::drop_guard(&composite))
and pass/store that guard into alloc_cancel_token (or adjust alloc_cancel_token
to accept and retain a CancellationTokenDropGuard alongside the
CancellationToken), leaving watcher logic unchanged so out.cancel() still
cancels all watchers when the guard is dropped.
In `@baml_language/crates/bex_vm/src/vm.rs`:
- Around line 3934-3944: The code currently accepts any heap object for
config_value via config_value.as_object_ptr() even when the expected type is an
Instance; update the branch that sets config_ptr (in vm.rs around the
config_value handling) to check the actual heap object's kind is
ObjectType::Instance before returning Some(ptr), and if it is not an Instance
return Err(VmInternalError::TypeError { expected:
Type::Object(ObjectType::Instance), got: self.type_of(&config_value) }.into());
ensure you use the VM/heap API that retrieves the object kind by pointer (the
same mechanism used elsewhere for type checks) so malformed bytecode cannot pass
non-Instance objects into UnscheduledFuture.config.
---
Outside diff comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 5089-5112: parse_type_primary currently only accepts
TokenKind::Word after a dot, so type paths like baml.spawn.CancelToken fail;
update parse_type_primary to mirror the same segment logic used in
parse_path_or_ident (or extract the segment predicate into a shared helper) to
accept TokenKind::Client, TokenKind::Spawn, and TokenKind::Await as valid path
segments after a Dot, ensuring type path parsing (e.g. in parse_type_primary)
uses the same check as the segment closure used when building
SyntaxKind::PATH_EXPR.
---
Nitpick comments:
In `@baml_language/crates/baml_builtins2/baml_std/baml/ns_spawn/spawn.baml`:
- Around line 104-112: The doc comment on the options function is stale about
TaskGroup not yet wired; update the comment for function options(group:
TaskGroup? = null, cancel: CancelToken? = null, detach: bool? = null) ->
SpawnConfig to reflect that TaskGroup is implemented and wired in this PR
(remove the "forward compatibility" note and state that group support and rate
limiting are active, and optionally mention FIFO rate limiter/Layers 8–9 as
implemented); keep the rest of the description about cancel and detach intact.
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 4515-4551: Add unit tests covering the new spawn grammar in this
parser module: create tests that exercise parse_spawn_expr including a valid
"spawn ... with baml.spawn.options(...) { ... }" case, a case with a trailing
comma before the body brace to verify recovery, a
missing-expression-after-`with` recovery case, and dotted keyword segments like
"baml.spawn.CancelToken" (these tests should invoke the parser on snippet
strings and assert the produced syntax kind is SPAWN_EXPR and that
error/recovery nodes are present as expected). Place tests in the same Rust file
as parse_spawn_expr (using #[cfg(test)] mod tests) and ensure they call the
parser entry used by parse_spawn_expr (so the code paths using
suppress_object_literal_depth, bump_contextual_with, and parse_expr_bp are hit);
run cargo test --lib to verify.
In `@baml_language/crates/bex_heap/src/gc.rs`:
- Around line 1904-1908: The tests currently allocate UnscheduledFuture
instances with UnscheduledFuture.config = None, so they don't exercise
pointer-tracing/fixup for non-null config fields; update or add a GC test that
allocates via tlab.alloc an Object::UnscheduledFuture where
UnscheduledFuture.config = Some(some_heap_object) (use the same helper objects
used elsewhere in the tests), hold a reference to that config across a GC,
trigger tracing/fixup, and assert the config pointer is preserved/updated after
collection; update both occurrences around the future_ptr allocation (the block
using tlab.alloc for future_ptr and the similar block at the later 2421-2425
region) to include a non-None config variant so tracing code for
UnscheduledFuture.config is covered.
In `@baml_language/crates/bex_vm/Cargo.toml`:
- Around line 35-45: The Cargo.toml currently enables tokio macros globally and
also repeats "macros" in the native target-specific tokio dependency; remove the
redundant "macros" from the native target entry so that the base tokio = {
workspace = true, features = ["macros"] } stays as-is and the
target.'cfg(not(target_arch = "wasm32"))'.dependencies tokio entry becomes tokio
= { workspace = true, features = ["rt"] } (leave wasm-bindgen-futures and the
wasm target block unchanged).
🪄 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: bfb670ed-4f24-4799-8d68-3d119bf7ef73
⛔ Files ignored due to path filters (114)
baml_language/Cargo.lockis excluded by!**/*.lockbaml_language/crates/baml_tests/snapshots/baml_src/_root.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/arrays.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/assignments.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/bigints.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/builtins.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/byte_strings.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/cancel_cascade.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/cancel_token.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/class_type_args_at_runtime.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/classes.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/closures.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/deep_copy.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/enums.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/env.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/exceptions.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/floats.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/for_loops.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/fs.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/functions.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/future_methods.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/glob.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/if_else.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/ints.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/is_operator.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/json_alias.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/json_auto_derive.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/json_parse_stringify.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/json_to_from_string.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/lambdas.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/lexical_scoping.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/maps.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/match_basics.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/match_optimization.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/match_types.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/null_handling.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/operators.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/optional_function_parameters.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/patterns_new_runtime.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/reflect_type_of.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/reflect_type_of_generic.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/shell.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/soundness.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/spawn_basic.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/spawn_name_object.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/spawn_semantics.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/spawn_throws.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/strings.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/task_group.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/type_error_repro.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/type_reflection.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/typed_inputs.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/typed_outputs.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/watch.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/baml_src/while_loops.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/__baml_std__/baml_tests__compiles____baml_std____06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_basic/baml_tests__compiles__test_expr_basic__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_basic/baml_tests__compiles__test_expr_basic__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_basic/baml_tests__compiles__test_expr_basic__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_basic/baml_tests__compiles__test_expr_basic__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_name_concat/baml_tests__compiles__test_expr_name_concat__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_name_concat/baml_tests__compiles__test_expr_name_concat__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_name_concat/baml_tests__compiles__test_expr_name_concat__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_name_concat/baml_tests__compiles__test_expr_name_concat__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_throwing_body/baml_tests__compiles__test_expr_throwing_body__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_throwing_body/baml_tests__compiles__test_expr_throwing_body__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_throwing_body/baml_tests__compiles__test_expr_throwing_body__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_expr_throwing_body/baml_tests__compiles__test_expr_throwing_body__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_old_and_new/baml_tests__compiles__test_old_and_new__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_old_and_new/baml_tests__compiles__test_old_and_new__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_old_and_new/baml_tests__compiles__test_old_and_new__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_old_and_new/baml_tests__compiles__test_old_and_new__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_raw_string_name/baml_tests__compiles__test_raw_string_name__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_raw_string_name/baml_tests__compiles__test_raw_string_name__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_raw_string_name/baml_tests__compiles__test_raw_string_name__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_raw_string_name/baml_tests__compiles__test_raw_string_name__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_with_not_keyword/baml_tests__compiles__test_with_not_keyword__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_with_not_keyword/baml_tests__compiles__test_with_not_keyword__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_with_not_keyword/baml_tests__compiles__test_with_not_keyword__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/test_with_not_keyword/baml_tests__compiles__test_with_not_keyword__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_basic/baml_tests__compiles__testset_basic__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_basic/baml_tests__compiles__testset_basic__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_basic/baml_tests__compiles__testset_basic__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_basic/baml_tests__compiles__testset_basic__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_dynamic/baml_tests__compiles__testset_dynamic__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_dynamic/baml_tests__compiles__testset_dynamic__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_dynamic/baml_tests__compiles__testset_dynamic__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_dynamic/baml_tests__compiles__testset_dynamic__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_nested/baml_tests__compiles__testset_nested__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_nested/baml_tests__compiles__testset_nested__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_nested/baml_tests__compiles__testset_nested__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_nested/baml_tests__compiles__testset_nested__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_vibes_nested/baml_tests__compiles__testset_vibes_nested__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_with_setup/baml_tests__compiles__testset_with_setup__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_with_setup/baml_tests__compiles__testset_with_setup__04_5_mir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_with_setup/baml_tests__compiles__testset_with_setup__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/compiles/testset_with_setup/baml_tests__compiles__testset_with_setup__06_codegen.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_name_type_error/baml_tests__diagnostic_errors__test_expr_name_type_error__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_name_type_error/baml_tests__diagnostic_errors__test_expr_name_type_error__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_with_runner/baml_tests__diagnostic_errors__test_expr_with_runner__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_with_runner/baml_tests__diagnostic_errors__test_expr_with_runner__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_wrong_runner/baml_tests__diagnostic_errors__test_expr_wrong_runner__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/diagnostic_errors/test_expr_wrong_runner/baml_tests__diagnostic_errors__test_expr_wrong_runner__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/diagnostic_errors/test_with_runner_ambiguity/baml_tests__diagnostic_errors__test_with_runner_ambiguity__03_hir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/snapshots/diagnostic_errors/test_with_runner_ambiguity/baml_tests__diagnostic_errors__test_with_runner_ambiguity__04_tir.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/src/compiler2_tir/snapshots/baml_tests__compiler2_tir__phase5__snapshot_baml_package_items.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snapis excluded by!**/*.snapbaml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snapis excluded by!**/*.snap
📒 Files selected for processing (36)
baml_language/crates/baml_builtins2/baml_std/baml/ns_spawn/spawn.bamlbaml_language/crates/baml_builtins2/src/lib.rsbaml_language/crates/baml_builtins2_codegen/src/codegen.rsbaml_language/crates/baml_compiler2_ast/src/ast.rsbaml_language/crates/baml_compiler2_ast/src/lower_expr_body.rsbaml_language/crates/baml_compiler2_emit/src/analysis.rsbaml_language/crates/baml_compiler2_emit/src/emit.rsbaml_language/crates/baml_compiler2_emit/src/stack_carry.rsbaml_language/crates/baml_compiler2_hir/src/builder.rsbaml_language/crates/baml_compiler2_mir/src/builder.rsbaml_language/crates/baml_compiler2_mir/src/ir.rsbaml_language/crates/baml_compiler2_mir/src/lower.rsbaml_language/crates/baml_compiler2_mir/src/optimize.rsbaml_language/crates/baml_compiler2_mir/src/pretty.rsbaml_language/crates/baml_compiler2_tir/src/builder.rsbaml_language/crates/baml_compiler2_tir/src/infer_context.rsbaml_language/crates/baml_compiler2_tir/src/throws_analysis.rsbaml_language/crates/baml_compiler_parser/src/parser.rsbaml_language/crates/baml_lsp2_actions/src/check.rsbaml_language/crates/baml_tests/baml_src/ns_cancel_token/cancel_token.bamlbaml_language/crates/baml_tests/baml_src/ns_task_group/task_group.bamlbaml_language/crates/baml_tests/tests/spawn_parallel.rsbaml_language/crates/bex_engine/src/lib.rsbaml_language/crates/bex_engine/src/thread.rsbaml_language/crates/bex_engine/tests/cancellation.rsbaml_language/crates/bex_engine/tests/task_group.rsbaml_language/crates/bex_heap/src/gc.rsbaml_language/crates/bex_vm/Cargo.tomlbaml_language/crates/bex_vm/src/package_baml/mod.rsbaml_language/crates/bex_vm/src/package_baml/root.rsbaml_language/crates/bex_vm/src/package_baml/spawn.rsbaml_language/crates/bex_vm/src/vm.rsbaml_language/crates/bex_vm_types/src/lib.rsbaml_language/crates/bex_vm_types/src/task_group.rsbaml_language/crates/bex_vm_types/src/types.rsbaml_language/crates/tools_onionskin/src/compiler.rs
| // Optional `with expr (, expr)*` clause. Suppress object literals | ||
| // for the same reason as the name: keep the body brace available. | ||
| if p.at_contextual_kw("with") { | ||
| p.bump_contextual_with(); | ||
| p.suppress_object_literal_depth += 1; | ||
| p.parse_expr_bp(1); | ||
| while p.eat(TokenKind::Comma) { | ||
| // Tolerate a trailing comma before the body brace. | ||
| if p.at(TokenKind::LBrace) { | ||
| break; | ||
| } | ||
| p.parse_expr_bp(1); | ||
| } | ||
| p.suppress_object_literal_depth -= 1; |
There was a problem hiding this comment.
Don't consume the spawn body when with has no config expression.
If the next token after with is {, parse_expr_bp(1) will parse the body block as the config expression. That turns a simple syntax error into a malformed SPAWN_EXPR and loses recovery for the real body.
Suggested fix
if p.at_contextual_kw("with") {
p.bump_contextual_with();
- p.suppress_object_literal_depth += 1;
- p.parse_expr_bp(1);
- while p.eat(TokenKind::Comma) {
- // Tolerate a trailing comma before the body brace.
- if p.at(TokenKind::LBrace) {
- break;
- }
- p.parse_expr_bp(1);
- }
- p.suppress_object_literal_depth -= 1;
+ if p.at(TokenKind::LBrace) {
+ p.error_unexpected_token("spawn config expression after 'with'".to_string());
+ } else {
+ p.suppress_object_literal_depth += 1;
+ p.parse_expr_bp(1);
+ while p.eat(TokenKind::Comma) {
+ // Tolerate a trailing comma before the body brace.
+ if p.at(TokenKind::LBrace) {
+ break;
+ }
+ p.parse_expr_bp(1);
+ }
+ p.suppress_object_literal_depth -= 1;
+ }
}🤖 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/baml_compiler_parser/src/parser.rs` around lines 4538 -
4551, The parser currently always calls p.parse_expr_bp(1) after
bump_contextual_with(), which lets a following `{` be consumed as the config
expression and eat the spawn body; instead, guard the config parse so it does
not run when the next token is a left brace: after p.bump_contextual_with()
check p.at(TokenKind::LBrace) and, if true, report a missing/empty config (or
recover) and skip parsing the expression so the body remains intact; only adjust
p.suppress_object_literal_depth and call p.parse_expr_bp(1) when the next token
is not TokenKind::LBrace, keeping the rest of the while p.eat(TokenKind::Comma)
logic unchanged to preserve comma handling for real config expressions in
SPAWN_EXPR.
| let is_spawn_config = matches!( | ||
| &cfg_ty, | ||
| Ty::Class(qn, _, _) | ||
| if qn.package().as_str() == "baml" | ||
| && qn.namespace().len() == 1 | ||
| && qn.namespace()[0].as_str() == "spawn" | ||
| && qn.name().as_str() == "SpawnConfig" | ||
| ); | ||
| // Don't double-report on an expression that already failed | ||
| // to type-check. | ||
| let unresolved = matches!(cfg_ty, Ty::Unknown { .. } | Ty::Error { .. }); | ||
| if !is_spawn_config && !unresolved { | ||
| self.context | ||
| .report_simple(TirTypeError::SpawnWithMustBeSpawnConfig, *with_id); |
There was a problem hiding this comment.
Expand aliases before SpawnConfig validation.
Line 3226 currently checks only a direct Ty::Class(...) shape. A with value typed via a type alias to baml.spawn.SpawnConfig can be incorrectly rejected with SpawnWithMustBeSpawnConfig.
💡 Suggested fix
for (idx, with_id) in with_exprs.iter().enumerate() {
let cfg_ty = self.infer_expr(*with_id, body);
if idx >= 1 {
self.context
.report_simple(TirTypeError::SpawnWithTooManyConfigs, *with_id);
continue;
}
+ let cfg_ty = self.expand_alias_chains(cfg_ty);
let is_spawn_config = matches!(
&cfg_ty,
Ty::Class(qn, _, _)
if qn.package().as_str() == "baml"
&& qn.namespace().len() == 1
&& qn.namespace()[0].as_str() == "spawn"
&& qn.name().as_str() == "SpawnConfig"
);
// Don't double-report on an expression that already failed
// to type-check.
let unresolved = matches!(cfg_ty, Ty::Unknown { .. } | Ty::Error { .. });📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let is_spawn_config = matches!( | |
| &cfg_ty, | |
| Ty::Class(qn, _, _) | |
| if qn.package().as_str() == "baml" | |
| && qn.namespace().len() == 1 | |
| && qn.namespace()[0].as_str() == "spawn" | |
| && qn.name().as_str() == "SpawnConfig" | |
| ); | |
| // Don't double-report on an expression that already failed | |
| // to type-check. | |
| let unresolved = matches!(cfg_ty, Ty::Unknown { .. } | Ty::Error { .. }); | |
| if !is_spawn_config && !unresolved { | |
| self.context | |
| .report_simple(TirTypeError::SpawnWithMustBeSpawnConfig, *with_id); | |
| let cfg_ty = self.expand_alias_chains(cfg_ty); | |
| let is_spawn_config = matches!( | |
| &cfg_ty, | |
| Ty::Class(qn, _, _) | |
| if qn.package().as_str() == "baml" | |
| && qn.namespace().len() == 1 | |
| && qn.namespace()[0].as_str() == "spawn" | |
| && qn.name().as_str() == "SpawnConfig" | |
| ); | |
| // Don't double-report on an expression that already failed | |
| // to type-check. | |
| let unresolved = matches!(cfg_ty, Ty::Unknown { .. } | Ty::Error { .. }); | |
| if !is_spawn_config && !unresolved { | |
| self.context | |
| .report_simple(TirTypeError::SpawnWithMustBeSpawnConfig, *with_id); |
🤖 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/baml_compiler2_tir/src/builder.rs` around lines 3226 -
3239, The check for SpawnConfig uses cfg_ty directly and fails for type aliases;
before computing is_spawn_config you should expand type aliases on cfg_ty (e.g.,
resolve through whatever alias-expansion helper or call you have) and then match
the expanded type against Ty::Class to detect baml.spawn.SpawnConfig; update the
code that computes is_spawn_config (and keep unresolved logic) to use the
expanded type so report_simple(TirTypeError::SpawnWithMustBeSpawnConfig,
*with_id) is only emitted when the underlying (expanded) type is not
SpawnConfig.
| let prev = std::panic::take_hook(); | ||
| std::panic::set_hook(Box::new(|_| {})); // suppress the diagnostic backtrace | ||
| let compiled = std::panic::catch_unwind(|| compile_for_engine(bad)); | ||
| std::panic::set_hook(prev); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify all global panic-hook mutations in this test area.
rg -n -C2 'std::panic::(take_hook|set_hook|catch_unwind)' baml_language/crates/bex_engine/tests/cancellation.rsRepository: BoundaryML/baml
Length of output: 375
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"
echo "== hook mutations =="
rg -n 'std::panic::(take_hook|set_hook|catch_unwind)' "$FILE" -C1
echo
echo "== test declarations =="
rg -n '#\[(test|tokio::test)\]' "$FILE"
echo
echo "== serial_test usage =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true
echo
echo "== context around lines 920-950 =="
nl -ba "$FILE" | sed -n '900,960p'Repository: BoundaryML/baml
Length of output: 813
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"
echo "== hook mutations =="
rg -n 'std::panic::(take_hook|set_hook|catch_unwind)' "$FILE" -C1
echo
echo "== test declarations =="
rg -n '#\[(test|tokio::test)\]' "$FILE"
echo
echo "== serial_test usage =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true
echo
echo "== context around lines 920-950 =="
nl -ba "$FILE" | sed -n '900,960p'Repository: BoundaryML/baml
Length of output: 813
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"
echo "== hook mutations =="
rg -n 'std::panic::(take_hook|set_hook|catch_unwind)' "$FILE" -C1
echo
echo "== test declarations =="
rg -n '#\[(test|tokio::test)\]' "$FILE"
echo
echo "== serial_test usage =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true
echo
echo "== context around lines 920-950 =="
nl -ba "$FILE" | sed -n '900,960p'Repository: BoundaryML/baml
Length of output: 813
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"
echo "== hook mutations =="
rg -n 'std::panic::(take_hook|set_hook|catch_unwind)' "$FILE" -C1
echo
echo "== test declarations =="
rg -n '#\[(test|tokio::test)\]' "$FILE"
echo
echo "== serial_test usage =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true
echo
echo "== context around lines 900-960 =="
nl -ba "$FILE" | sed -n '900,960p'Repository: BoundaryML/baml
Length of output: 813
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_engine/tests/cancellation.rs"
echo "== context around hook mutation (lines ~910-945) =="
awk 'NR>=900 && NR<=950 {printf "%d:%s\n", NR, $0}' "$FILE"
echo
echo "== serial_test usage in file =="
rg -n 'serial_test|#[[:space:]]*serial' "$FILE" || true
echo
echo "== panic hook mutations in this crate tests =="
rg -n 'std::panic::(take_hook|set_hook)' baml_language/crates/bex_engine || true
echo
echo "== panic hook mutations repo-wide (scoped to baml_language) =="
rg -n 'std::panic::(take_hook|set_hook)' baml_language || trueRepository: BoundaryML/baml
Length of output: 2662
Avoid mutating the global std::panic hook in concurrent tests (cancellation.rs).
In baml_language/crates/bex_engine/tests/cancellation.rs (lines 933–936), the test swaps the process-wide panic hook via std::panic::take_hook / std::panic::set_hook to suppress diagnostics. The file contains many #[tokio::test] cases and no serialization, so this can interfere with other concurrently running tests and change/suppress their panic output.
Suggested fix
- let prev = std::panic::take_hook();
- std::panic::set_hook(Box::new(|_| {})); // suppress the diagnostic backtrace
- let compiled = std::panic::catch_unwind(|| compile_for_engine(bad));
- std::panic::set_hook(prev);
+ let compiled = std::panic::catch_unwind(|| compile_for_engine(bad));📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let prev = std::panic::take_hook(); | |
| std::panic::set_hook(Box::new(|_| {})); // suppress the diagnostic backtrace | |
| let compiled = std::panic::catch_unwind(|| compile_for_engine(bad)); | |
| std::panic::set_hook(prev); | |
| let compiled = std::panic::catch_unwind(|| compile_for_engine(bad)); |
🤖 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/bex_engine/tests/cancellation.rs` around lines 933 -
936, The test mutates the global panic hook (take_hook/set_hook) which can
interfere with other concurrent tokio tests; remove the hook mutation and
instead rely on std::panic::catch_unwind around the call to
compile_for_engine(bad) (or run the panic-producing call in an isolated
subprocess if you must suppress diagnostics), so do not call
std::panic::take_hook or std::panic::set_hook in cancellation.rs and keep only
the catch_unwind wrapping compile_for_engine to observe the panic payload.
| /// A member registered with a group (synchronously, at spawn time) that has not | ||
| /// yet been admitted. The spawned body task [`acquire`](TaskGroupTicket::acquire)s | ||
| /// it to await a concurrency slot. | ||
| pub struct TaskGroupTicket { | ||
| group: Arc<TaskGroupInner>, | ||
| id: u64, | ||
| /// `None` when admitted at registration; `Some` while parked. | ||
| rx: Option<oneshot::Receiver<()>>, | ||
| /// The member's effective token (only consulted on the parked path). | ||
| cancel: CancellationToken, | ||
| } |
There was a problem hiding this comment.
Dropping TaskGroupTicket leaks group state.
register() consumes capacity before the caller awaits acquire(), but the ticket itself has no cleanup path. If an immediately-admitted ticket is dropped before acquire(), active stays permanently inflated; if a queued ticket is dropped, it remains counted until some unrelated promotion/cancel path cleans it up. Because this type is publicly re-exported, that footgun is reachable outside the engine too.
Either make the ticket internal, or teach it to unregister itself on drop and mark the successful acquire() path as consumed so the permit owns cleanup from that point onward.
Also applies to: 193-231
🤖 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/bex_vm_types/src/task_group.rs` around lines 68 - 78,
TaskGroupTicket currently leaks group state when dropped; implement ownership
cleanup by adding a Drop for TaskGroupTicket that unregisters the ticket from
TaskGroupInner unless the ticket has already been consumed by a successful
acquire(); modify TaskGroupTicket.acquire() to atomically mark the ticket as
consumed (e.g., set rx to None or flip a bool/AtomicBool) and return the
permit/guard that becomes responsible for decrementing active on its Drop, and
add an internal TaskGroupInner::unregister(id) (or call the existing decrement
path) used by Drop to decrement active/cancel queued state for tickets that were
never acquired; ensure both the parked (rx/ cancel) and immediately-admitted
paths set the consumed flag so Drop does not double-unregister.
| /// Change the concurrency cap. Increasing admits parked waiters up to the | ||
| /// new cap immediately; decreasing never preempts running members (new | ||
| /// admissions simply wait); `0` pauses the group. | ||
| pub fn set_limit(&self, limit: usize) { | ||
| let mut st = self.lock(); | ||
| st.limit = limit; | ||
| Self::promote(&mut st); | ||
| } |
There was a problem hiding this comment.
set_limit() only promotes one waiter today.
promote() breaks after the first successful grant, so widening the cap from 1 to N leaves spare capacity idle until some later drop/cancel path runs. That contradicts the Line 156-158 contract and underutilizes the group after set_limit().
Suggested fix
- break;Please also add a unit test that raises the limit above the current active count and asserts that admission catches up immediately.
Also applies to: 251-274
🤖 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/bex_vm_types/src/task_group.rs` around lines 156 - 163,
set_limit currently calls Self::promote(&mut st) which only grants one waiter
because promote returns after the first success; update promote (or add a new
loop in set_limit) to repeatedly promote waiters until either st.limit ==
st.active or no more waiters remain so widening the cap from 1 to N fills all
spare slots immediately; modify the logic in promote (referencing the promote
function) to not break after the first grant (or call promote in a loop from
set_limit) and ensure it respects paused (limit==0) behavior. Also add a unit
test that constructs a TaskGroup, sets limit=1, parks enough waiters to exceed
1, then calls set_limit to a value > active and asserts that the group’s active
count increases to the new limit (and all eligible waiters are admitted) right
after set_limit returns.
| fn any(vm: &mut BexVm, tokens: &[Value]) -> Value { | ||
| // A fresh composite token that fires when any input fires. One watcher | ||
| // task per input links `input.cancelled() -> composite.cancel()`; each | ||
| // watcher self-terminates when the composite is cancelled (directly or | ||
| // by a sibling), so they never outlive the composite. Cancelling the | ||
| // composite does not propagate back to the inputs (one-directional). | ||
| let composite = CancellationToken::new(); | ||
| for &token in tokens { | ||
| let Some(input) = as_cancellation_token(vm, token) else { | ||
| continue; | ||
| }; | ||
| let out = composite.clone(); | ||
| let watcher = async move { | ||
| tokio::select! { | ||
| biased; | ||
| () = input.cancelled() => out.cancel(), | ||
| () = out.cancelled() => {} | ||
| } | ||
| }; | ||
| #[cfg(not(target_arch = "wasm32"))] | ||
| tokio::spawn(watcher); | ||
| #[cfg(target_arch = "wasm32")] | ||
| wasm_bindgen_futures::spawn_local(watcher); | ||
| } | ||
| alloc_cancel_token(vm, composite) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does tokio_util::sync::CancellationToken::cancelled()resolve when the last clone is dropped, or only after an explicitcancel()? Is CancellationTokenDropGuard the intended way to make watcher tasks stop on drop?
💡 Result:
No, tokio_util::sync::CancellationToken::cancelled does not resolve when the last clone is dropped; it only resolves when the token is explicitly cancelled via the cancel method (or when it is already in a cancelled state) [1][2]. The CancellationToken::drop_guard method is the intended way to trigger cancellation automatically when a specific scope is exited (i.e., when the guard is dropped) [1][3]. To implement this pattern, you call.drop_guard on your CancellationToken to obtain a DropGuard. As long as this guard exists, the token remains uncancelled (unless cancelled by other means). When the guard goes out of scope and is dropped, it automatically calls cancel on the associated token [1][3]. If you wish to prevent this automatic cancellation (e.g., because the task completed successfully), you can use the disarm method on the guard to consume it without triggering the cancellation [3].
Citations:
- 1: https://doc.servo.org/tokio_util/sync/struct.CancellationToken.html
- 2: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html
- 3: https://docs.rs/tokio-util/latest/src/tokio_util/sync/cancellation_token/guard.rs.html
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_vm/src/package_baml/spawn.rs"
echo "== File top =="
wc -l "$FILE"
rg -n "fn any\\(" "$FILE"
rg -n "alloc_cancel_token\\(|as_cancellation_token\\(|struct .*Cancel|DropGuard|drop_guard|disarm" "$FILE"
echo
echo "== any() implementation region =="
sed -n '60,140p' "$FILE"
echo
echo "== alloc_cancel_token / related definitions =="
rg -n "fn alloc_cancel_token|alloc_cancel_token\\(" "$FILE"
# Print some nearby context around each hit
for m in $(rg -n "alloc_cancel_token\\(|as_cancellation_token\\(" "$FILE" | cut -d: -f1 | sort -u); do
echo "-- around line $m --"
sed -n "$((m-25)),$((m+25))p" "$FILE"
doneRepository: BoundaryML/baml
Length of output: 20902
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "pub (type|use) CancellationToken|struct CancellationToken|tokio_util::sync::CancellationToken" -S baml_language/crates/bex_vm_types 2>/dev/null || true
rg -n "drop_guard|CancellationTokenDropGuard|disarm\\(" -S baml_language/crates/bex_vm/src/package_baml/spawn.rs baml_language/crates/bex_vm_types 2>/dev/null || trueRepository: BoundaryML/baml
Length of output: 250
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "CancellationToken" baml_language/crates/bex_vm_types -S | head -n 50Repository: BoundaryML/baml
Length of output: 1903
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Confirm what CancellationToken actually is in this repo
rg -n "tokio_util::sync::CancellationToken|CancellationTokenDropGuard|drop_guard\\(|disarm\\(" -S baml_language/crates/bex_vm_types baml_language/crates/bex_vm/src/package_baml/spawn.rs
# Show a few key lines where CancellationToken is defined/re-exported
rg -n "CancellationToken" -S baml_language/crates/bex_vm_types | head -n 80Repository: BoundaryML/baml
Length of output: 2094
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Confirm what CancellationToken actually is in this repo
rg -n "tokio_util::sync::CancellationToken|CancellationTokenDropGuard|drop_guard\\(|disarm\\(" -S baml_language/crates/bex_vm_types baml_language/crates/bex_vm/src/package_baml/spawn.rs
# Show a few key lines where CancellationToken is defined/re-exported
rg -n "CancellationToken" -S baml_language/crates/bex_vm_types | head -n 80Repository: BoundaryML/baml
Length of output: 2094
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="baml_language/crates/bex_vm/src/package_baml/spawn.rs"
echo "== Search for drop-guard patterns in spawn.rs =="
rg -n "drop_guard|CancellationTokenDropGuard|disarm\\(" "$FILE" || true
echo
echo "== Inspect alloc_cancel_token definition (ownership) =="
sed -n '1,80p' "$FILE"
echo
echo "== Inspect any() watcher lifetime details =="
sed -n '80,130p' "$FILE"Repository: BoundaryML/baml
Length of output: 5820
Fix potential watcher task leak in baml.spawn.CancelToken.any when the composite token is dropped
- In
baml_language/crates/bex_vm/src/package_baml/spawn.rs(lines 84-109), each spawned watcher capturesout = composite.clone()and waits oninput.cancelled()orout.cancelled(). Dropping the returned compositeCancelTokenonly drops one clone;tokio_util::sync::CancellationToken::cancelled()does not resolve on last-clone drop, so watchers can remain pending forever and accumulate. - Allocate the heap
CancelTokenhandle so it owns a drop-triggered cancellation (e.g., store aCancellationTokenDropGuardfromCancellationToken::drop_guard()alongside the token) to cancel the composite when the VM handle is dropped.
🤖 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/bex_vm/src/package_baml/spawn.rs` around lines 84 - 109,
The watchers can leak because CancellationToken::cancelled() doesn't fire when
the last clone is dropped; fix by creating a CancellationTokenDropGuard (via
CancellationToken::drop_guard()) for the composite token and store that guard
with the returned VM token so dropping the VM handle triggers cancel();
specifically, in any() create let guard = composite.drop_guard() (or
CancellationToken::drop_guard(&composite)) and pass/store that guard into
alloc_cancel_token (or adjust alloc_cancel_token to accept and retain a
CancellationTokenDropGuard alongside the CancellationToken), leaving watcher
logic unchanged so out.cancel() still cancels all watchers when the guard is
dropped.
| let config_ptr = if config_value.is_null() { | ||
| None | ||
| } else if let Some(ptr) = config_value.as_object_ptr() { | ||
| Some(ptr) | ||
| } else { | ||
| return Err(VmInternalError::TypeError { | ||
| expected: Type::Object(ObjectType::Instance), | ||
| got: self.type_of(&config_value), | ||
| } | ||
| .into()); | ||
| }; |
There was a problem hiding this comment.
Reject non-Instance heap objects here.
This branch currently accepts any heap object as config, even though the error type says ObjectType::Instance. That means malformed bytecode can smuggle a string/array/function into UnscheduledFuture.config and only fail later in the engine.
Suggested fix
- let config_ptr = if config_value.is_null() {
- None
- } else if let Some(ptr) = config_value.as_object_ptr() {
- Some(ptr)
- } else {
+ let config_ptr = if config_value.is_null() {
+ None
+ } else if let Some(ptr) = config_value.as_object_ptr() {
+ match self.get_object(ptr) {
+ Object::Instance(_) => Some(ptr),
+ _ => {
+ return Err(VmInternalError::TypeError {
+ expected: Type::Object(ObjectType::Instance),
+ got: self.type_of(&config_value),
+ }
+ .into());
+ }
+ }
+ } else {
return Err(VmInternalError::TypeError {
expected: Type::Object(ObjectType::Instance),
got: self.type_of(&config_value),
}
.into());
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let config_ptr = if config_value.is_null() { | |
| None | |
| } else if let Some(ptr) = config_value.as_object_ptr() { | |
| Some(ptr) | |
| } else { | |
| return Err(VmInternalError::TypeError { | |
| expected: Type::Object(ObjectType::Instance), | |
| got: self.type_of(&config_value), | |
| } | |
| .into()); | |
| }; | |
| let config_ptr = if config_value.is_null() { | |
| None | |
| } else if let Some(ptr) = config_value.as_object_ptr() { | |
| match self.get_object(ptr) { | |
| Object::Instance(_) => Some(ptr), | |
| _ => { | |
| return Err(VmInternalError::TypeError { | |
| expected: Type::Object(ObjectType::Instance), | |
| got: self.type_of(&config_value), | |
| } | |
| .into()); | |
| } | |
| } | |
| } else { | |
| return Err(VmInternalError::TypeError { | |
| expected: Type::Object(ObjectType::Instance), | |
| got: self.type_of(&config_value), | |
| } | |
| .into()); | |
| }; |
🤖 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/bex_vm/src/vm.rs` around lines 3934 - 3944, The code
currently accepts any heap object for config_value via
config_value.as_object_ptr() even when the expected type is an Instance; update
the branch that sets config_ptr (in vm.rs around the config_value handling) to
check the actual heap object's kind is ObjectType::Instance before returning
Some(ptr), and if it is not an Instance return Err(VmInternalError::TypeError {
expected: Type::Object(ObjectType::Instance), got: self.type_of(&config_value)
}.into()); ensure you use the VM/heap API that retrieves the object kind by
pointer (the same mechanism used elsewhere for type checks) so malformed
bytecode cannot pass non-Instance objects into UnscheduledFuture.config.
|
Superseded by #3625, which folds the spawn-options work and the BEP-034 future combinators into a single PR. |
BEP-034 — concurrency via `spawn` / `await`. Single PR covering the spawn-options core and the future combinators (supersedes BoundaryML#3596). ## Public BAML API (BEP-034 concurrency) This is the complete user-facing surface this PR adds. Everything below is callable from `.baml` source. ### Syntax ```baml spawn { body } // -> Future<T, E>; runs body concurrently await f // -> T; blocks until f settles, re-throws its error spawn with t1 with t2 { body } // middleware: applies transformers left-to-right ``` - `spawn { e }` evaluates to `Future<T, E>` where `T` is the body's value type and `E` its throws type. The body starts running immediately; the future is the handle. - `await f` suspends until `f` settles, then yields its value or re-throws its error. `await` **distributes over a union of futures**: `await (Future<A,E1> | Future<B,E2>)` yields `A | B` (the error side is contributed to the awaiter's throws surface). - `spawn ... with <transformer> ...` runs each transformer over an implicit `SpawnParams` before the runtime creates the future (see middleware below). ### `baml.future` — futures & combinators ```baml class Future<T, E> { fn cancel(self) -> bool throws never // request cancel; false if already settled fn is_settled(self) -> bool throws never // reached Ready | Error | Cancelled fn is_cancelled(self) -> bool throws never fn is_result(self) -> bool throws never // settled with a value fn is_error(self) -> bool throws never // settled with an error fn state(self) -> FutureState throws never } enum FutureState { Pending Ready Error Cancelled } // Combinators over a homogeneous Future<T,E>[] — each returns a new Future, so they compose. fn all_complete<T, E>(futures: Future<T, E>[]) -> Future<T[], E> // all values in input order; losers KEEP RUNNING on failure; throws first error (input order) fn all<T, E>(futures: Future<T, E>[]) -> Future<T[], E> // all values in input order; on first failure the rest are CANCELLED, then error re-thrown (JS Promise.all) fn race<T, E>(futures: Future<T, E>[]) -> Future<T, E> // first to settle (success OR failure) wins; losers cancelled (JS Promise.race); empty array never settles fn any<T, Err>(futures: Future<T, Err>[]) -> Future<T, AllFailed<Err>> // first to SUCCEED wins; losers cancelled; if all fail, throws AllFailed<Err> (JS Promise.any) class AllFailed<E> { errors E[] } // thrown by `any` when every input failed; errors in input order ``` ### `baml.spawn` — spawn options & `with` middleware ```baml // Every `spawn` implicitly builds a SpawnParams; the `with` pipeline transforms it. // A transformer is any function (SpawnParams<T,E>) -> SpawnParams<U,F>. class SpawnParams<T, E> { body () -> T throws E name string? group TaskGroup? cancel CancelToken? detach bool } // The built-in config transformer. `cancel` links a token; `detach = true` opts out of the // parent→child cancel cascade and routes unhandled errors to the root task; `group` enrolls // the spawn in a TaskGroup (parks FIFO until admitted). Leaves `body`/types unchanged. fn options<T, E>(group: TaskGroup? = null, cancel: CancelToken? = null, detach: bool? = null) -> (SpawnParams<T, E>) -> SpawnParams<T, E> throws never class TaskGroup { // caps concurrency; excess spawns queue FIFO fn new(limit: int, name: string? = null) -> TaskGroup throws never fn cancel(self, pending: bool? = true, active: bool? = true) -> int throws never // returns # signalled fn set_limit(self, limit: int) -> void throws never // raise admits queued; lower never preempts; 0 pauses fn limit(self) -> int throws never fn name(self) -> string? throws never fn active_count(self) -> int throws never fn queued_count(self) -> int throws never } class CancelToken { // cooperative, one-shot; fires at next await fn new() -> CancelToken throws never fn any(tokens: CancelToken[]) -> CancelToken throws never // fires when ANY input fires (one-directional) fn cancel(self) -> int throws never // 1 if this call transitioned it, else 0 fn is_cancelled(self) -> bool throws never } ``` A cancelled task throws `baml.panics.Cancelled` at its next `await`. ### Writing your own middleware A transformer is just a function returning `(SpawnParams<T,E>) -> SpawnParams<U,F>`. It can set config fields, wrap `body` (retry/timing), or replace it entirely (a type-changing transformer — e.g. a fallback that catches and erases the error type to `null`): ```baml function withFallback<T, E>(default_value: T) -> (SpawnParams<T, E>) -> SpawnParams<T, null> throws never { (params) -> { let original = params.body; SpawnParams { body: () -> { (original()) catch (e) { let e => default_value } }, name: params.name, group: params.group, cancel: params.cancel, detach: params.detach, } } } // usage — `f : Future<int, null>`, so awaiting needs no catch: let f = spawn with withFallback(99) { flaky() }; ``` Transformers run **eagerly at the spawn site**, so a throwing transformer throws there (and is part of the caller's throws surface). Wrong-shaped transformers are rejected at compile time with a concrete "must return a SpawnParams" diagnostic. ## Spawn options - `spawn ... with baml.spawn.options(group?, cancel?, detach?)` — config-only `with` clause - `CancelToken` (`new` / `any` / `cancel` / `is_cancelled`) + parent→child cancel cascade - `TaskGroup` rate limiting (FIFO admission, exact counts) - `detach` (fresh cancel token; unhandled errors routed to the root task) - strict `with` validation (must be a `baml.spawn.SpawnConfig`) ## Future combinators (`baml.future`) - `race` / `any` / `all` / `all_complete` — pure-BAML stdlib over a new 1-byte `AwaitAny` engine primitive (`__await_any: Future<T,E>[] -> int throws never`, suspends until the first input settles) - `AllFailed<E>` aggregate error class for `any` - losers cancelled (`race`/`any`/`all`) or left running (`all_complete`) per the BEP table ## Engine/runtime fixes found along the way - **fire-and-forget errors (bug 2):** a child's unhandled throw is now *deferred* (GC-rooted stash; future stays Pending with its wake signal fired) and consumed at the single observation point (`future_ready`, now `&mut`). The spawner's post-await drain surfaces only never-observed errors — a sibling handling a child's error no longer races the spawner into a spurious `UnhandledThrow`. (`tests/fire_and_forget.rs`) - **AwaitAny index desync (bug 3):** global-index bookkeeping fix that broke `any` - **bare-bind catch arms (bug 4):** `let e =>` in a generic fn emitted a constant-false `IsType` (TypeVar→Void erasure) and silently rethrew; bare binds are irrefutable — no runtime test - **copy propagation** substitutes `Terminator::Await` operands (`await <param>` inside `.map` closures) - unannotated lambda params infer from generic array elements mentioning only the enclosing fn's rigid generics ## Generics: inferred type args at runtime - TIR records call-site inferred bindings (`call_type_instantiations`); MIR threads them to `frame.type_args` (`LoadType`/`TypeArgRef`) — instances built inside generic fns carry real `class_type_args`, and typed arms like `let e: AllFailed<string>` match (`tests/generics_runtime.rs`) - typed arms naming enclosing TypeVars (`let e: AllFailed<E>`) lower via type-arg templates instead of erasing ## Canary merge (f5df38a) Absorbs the spawn/GC permit deadlock fix (full `baml_tests` now completes on this branch), BEP-044/BEP-57, instantiation expressions, bounds, and interface-dispatch type-arg threading. Canary's strict rigid-var checking exposed pre-existing inference gaps, fixed here: - `await f` contributes the future's `E` to escaping throws - callee generics matched only against rigid caller TypeVars now bind (`map`'s `U:=T`) - lambda *effective* throws feed callee throws-generics — lifts the documented "current limitation" (covered callback throws no longer false-positive) and violations name concrete types - **miscompile fix:** `ShortCircuit` emit assumed a stack-carried (PhiLike) destination; with `LoadType` before call args the carry is validly rejected, so `a || b` as an inferred-generic call argument read an uninitialized local. Emit now materializes the destination on the short-circuit edge (no-op for PhiLike). - std `any<T, E>` → `any<T, Err>` (works around generic-param name capture vs `map<U, E>`; proper fix tracked separately) ## `with` middleware (c37027a) Full BEP middleware: `spawn ... with a, b { body }` builds a `SpawnParams<T,E>` and applies each transformer left-to-right — any plain BAML function `(SpawnParams<T,E>) -> SpawnParams<U,F>` (wrap/replace body, set config, change types). `baml.spawn.options` is now a pure-BAML transformer (native SpawnConfig deleted). Flushed out + fixed three language gaps: keyword segments in type paths, lambdas as block tail expressions, and unannotated lambdas now INFER their throws surface from the body (two tests asserting the old throws-never default updated). Tests: `bex_engine/tests/middleware.rs` incl. the BEP's `withRetry`. ## Complete bug ledger (every bug found & fixed on this branch) **Engine / runtime** 1. **Fire-and-forget errors bypassed consumers** — a spawned child's unhandled throw settled its Future eagerly AND queued on the spawner; a sibling that awaited + handled the error was invisible to the engine, so the spawner re-surfaced an already-handled error as `UnhandledThrow`. Fixed with the deferred-error model: the error parks in a GC-rooted stash, the future stays Pending (wake signal fired), `future_ready` is the single observe-and-consume point, and the spawner's post-await drain surfaces only never-observed errors. 2. **`AwaitAny` index desync** — global-index bookkeeping desync broke `any` (wrong winner index reported). 3. **Copy propagation didn't substitute `Terminator::Await` operands** — `await <param>` inside a `.map` closure read an uninitialized slot. 4. **`ShortCircuit` emit miscompile** — the emitter assumed the destination is always stack-carried (PhiLike); when inferred-generic calls push `LoadType` before args the carry is validly rejected, so `a || b` as a call argument left the value on the stack and read an uninitialized local (`true || false` evaluated false). Emit now materializes the destination on the short-circuit edge; MIR normalizes projection destinations. *Latent before the canary merge — the suite deadlocked before reaching the `||` fixtures.* **Type system / generics** 5. **Bare-bind catch arms in generic fns always rethrew** — `let e =>` emitted an `IsType` from its inferred type; a rigid TypeVar erases to `Ty::Void` in MIR making the test constant-false. Bare binds are irrefutable — no runtime test emitted (also drops tautological field tests in destructures). 6. **Inferred call-site type args were discarded** — TIR ran inference, substituted the return type, and threw the bindings away; MIR emitted frame type args only for explicit `<...>` call sites. Instances built inside generic fns carried empty `class_type_args`; typed arms like `let e: AllFailed<string>` never matched. Bindings now recorded (`call_type_instantiations`) and threaded to `frame.type_args`. 7. **Typed arms naming enclosing TypeVars compiled to constant-false** — `let e: AllFailed<E>` inside `any<T, E>` erased E→Void; now lowers via `TypeArgRef` templates resolved against the frame. 8. **Recorded instantiations poisoned by fresh literal types** — `mk("hi")` recorded `T = Literal("hi")` so `is Box<string>` compared unequal; recordings now `widen_fresh()`. Plus generic→generic chains recorded `Unknown` (TypeVar actuals refused); fixed with an allow-typevars backfill. 9. **Unannotated lambda params didn't infer from generic array elements** whose type only mentions the enclosing fn's rigid generics. **Throws tracking** *(pre-existing gaps exposed by canary's strict rigid-var checking)* 10. **`await f` contributed nothing to escaping throws** — the future's `E` is now part of the awaiting body's throw set. 11. **Callee generics matched only against rigid caller TypeVars never bound** — `map`'s `U:=T` in `futures.map((f) -> { await f })` was skipped as a TypeVar actual, leaving `U[]` in result types. 12. **Rigid TypeVar throw-facts collapsed lambda surfaces to `Never`** — only genuinely open facts (fresh effect slots, Unknown) collapse now. 13. **Callee throws-generics fed from lambda SURFACE instead of effective throws** — an omitted-throws lambda's open surface left `map`'s `E` unbound (leaking a raw TypeVar) or symbolic where the body demonstrably throws. Now fed from the side-table effective set: a non-throwing callback binds `E:=never`, a throwing one binds its concrete type. **Lifts a documented "current limitation"**: a callback throw covered by the caller's declared throws no longer false-positives, and violations name concrete types instead of symbolic `E`. **Parser / lowering** *(flushed out by the middleware work)* 14. **`spawn`/`await` keywords rejected as path segments** in type annotations (`baml.spawn.SpawnParams`) — parser type-path loop + CST dotted-name extractor. 15. **A lambda as a block's tail expression was silently dropped** from block elements — the block typed as void ("missing return value"); any function returning a transformer hit it (the std's `json` code had been dodging it with explicit `return`). 16. **Unannotated lambdas defaulted to `throws never` with a local violation** — they now infer their throws surface from the body (required for body wraps like `() -> { original() }` whose throws is a generic `E`). Omitted throws on callback *params* remains effect-polymorphic; explicit `throws` contracts remain enforced (verified: E0096 fires through the forwarding chain — an enforcement that itself depends on fixes 10–13). **Known issues (documented, not fixed here)** - Generic-param name capture: a callee's generic (`map<U, E>`) sharing a name with a caller's generic false-binds through the receiver-substituted signature. Worked around (`any<T, E>` → `any<T, Err>`); proper fix is alpha-renaming on receiver substitution. - `await` does not yet distribute over unions of futures (BEP §await-distributes). ## `await` over unions (94d2b46) Awaiting `Future<A, E1> | Future<B, E2>` types as `A | B` with both error types tracked (BEP §await-distributes). Tests: `bex_engine/tests/await_union.rs`. ## Review hardening (94d2b46) CodeRabbit batch: `any` errors in INPUT order (+test) · explicit lambda throws contracts preserved · `with`-transformer throws contributed at spawn · copy-prop protects `await xs[_i]` · `TaskGroup.set_limit(+N)` admits up to the cap (+test) · deferred-error hygiene on cancel/fulfill · VM Spawn config validation · unified/completed generics walkers. Plus the CI fix: `Object` enum exceeded its 64-byte budget under `heap_debug` (`UnscheduledFuture` boxed) — the cause of the all-legs CI failure. ## Canary merge round 4 (8b2c403) Canary's `c957417e7` ([codex] BoundaryML#3702) was a third independent implementation of inferred-generic runtime type args; this merge converges on it and fixes what the convergence broke: 1. **Adopted canary's CallPlan pipeline for inferred type args** — removed our parallel `call_type_instantiations` TIR→MIR fallback (dead code after the switch). Canary's ShortCircuit emit fix (`store_on_taken_path`) is the same insight as ours; took theirs. 2. **`runtime_call_type_args` lost literal widening** — canary's recording threads `T = literal "hi"` for `mk("hi")`, so an escaped instance never matches `is Box<string>` (runtime class-arg comparison is invariant). Restored `widen_fresh` on inferred bindings. 3. **Canary's stricter call-result recheck false-positived on `unknown` holes** — `spawn with` middleware checks transformers against `(SpawnParams<T,E>) -> SpawnParams<unknown, unknown>`; the normalizer implements class-arg invariance as structural equality, so the holes (and `BuiltinUnknown` from erased unresolved typevars, e.g. a variable-bound transformer; and equivalent spellings like `T = int | 99` from fallback-arg joins) all failed E0001. `is_subtype` now compares same-class args pairwise: holes match anything, concrete args must be **mutual** subtypes (invariance as equivalence). Concrete mismatches — including canary's new iterator-stdlib diagnostics — still report. 4. **`heap_debug` feature gap** — `FutureRead::ErrorPending` arm added to the heap debugger's validity walk. 5. **`task_group_cancel_cancels_members` timing** — the promptness bound times `run_main` *including compilation*; the stdlib grew (ns_iter). 2s → 5s, still far under the 10s sleep it guards. Closes the deferred interface-dispatch inferred-type-args item (canary's BoundaryML#3702 ships it with runtime regressions). Verified: `cargo test --workspace --all-features` — 185 suites / 0 failures (sdk_test_* excluded as in CI; they require cargo-nextest setup scripts) + prek clean, rustc 1.93.0. ## Deferred (follow-up branch) - `Result<T, E>` type design + `all_settled`, `.then()` - cancellation-test panic-hook hygiene Tests: `bex_engine` 227/0 (incl. `combinators`, `fire_and_forget`, `generics_runtime`, `cancellation`, `task_group`); `baml_tests` diagnostics 418/418, runtime fixtures 1650/1650 (one timing-sensitive fixture flakes only under full parallel suite load); snapshots regenerated. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * spawn ... with middleware and options(...) for spawn configuration; CancelToken and TaskGroup APIs; future combinators all_complete, all, race, any; await-any (returns winning input index) * **Tests** * Comprehensive BEP-034 tests for spawn options, TaskGroup, CancelToken, combinators, middleware, fire-and-forget, and runtime behavior * **Bug Fixes** * Improved child-task error propagation and deferred fire-and-forget error semantics across await/catch boundaries <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Implements the spawn-options core of BEP-034 — the
withclause onspawnand the primitives it configures. Builds on the mergedspawn/await/Future<T,E>foundation.What's included (4 commits)
with baml.spawn.options(cancel = ...)+CancelToken— thewithclause end-to-end (parser → AST → TIR → MIR → emit → VM → engine), thebaml.spawnnamespace, andCancelToken(new/any/cancel/is_cancelled). A user token links into a spawn's effective cancel token via a self-terminating watcher; firing it cancels the child andawaitre-throwsCancelled(catchable).detach = true+OmittedArgcodegen fix — a detached spawn gets a fresh effective token (parent cascade/throw doesn't reach it) and routes unhandled fire-and-forget errors to the root task. Also fixes the codegen so optional builtin params (incl. primitives likebool?) accept an omitted argument (general improvement).TaskGrouprate limiting —TaskGroup.new(limit, name?),cancel(pending?, active?),set_limit,limit,name,active_count,queued_count, andoptions(group = ...). Members register synchronously at spawn (socancel()/counts are exact immediately) and park asynchronously for a slot (FIFO, without holding the heap permit), releasing on settle. Admission is cancel-biased so agroup.cancel()'d member never runs.withvalidation +spawn_parallelfix — TIR rejectswithexpressions that aren't abaml.spawn.options(...)config (type-based, solet c = options(...); spawn with cworks too). Also fixesspawn_parallel.rs, which was a false-positive (it usedsys_ops, whosesleepisUnsupported, so its "parallel sleeps" were no-ops) — it now usessys_nativereal sleeps and genuinely verifies concurrency.Notable decisions / findings
baml.spawncollided with thespawnkeyword →spawn/awaitare now accepted as member-path segments after.(mirrors the existingKW_CLIENTprecedent), sobaml.spawn.options(...)parses exactly as the BEP writes it.limit=3runs three 200ms sleeps in ~200ms;limit=1serializes them (~600ms).TaskGroup.new,CancelToken.new/any) use the existing non-selfbuiltin-fn mechanism (likeint.parse).Testing
17 engine cancellation tests, 7 TaskGroup tests (+2 admission unit tests),
ns_cancel_token/ns_task_groupBAML fixtures,baml_src(1500+ BAML tests + bytecode/CLI targets),baml_tests --lib, plus parser/AST/MIR/emit/codegen suites — all green; clippy clean; snapshots regenerated.Deferred (out of this scope)
Combinators (
race/any/all/all_complete/all_settled) +Future.then(), the full user-middleware system (SpawnParamstransformers),await-over-unions, shielding,Duration/CancelToken.after, andTaskGroup.on_event.Summary by CodeRabbit
Release Notes
New Features
spawn ... with baml.spawn.options(...)syntax to configure spawned tasksCancelTokenfor canceling spawned operationsTaskGroupfor rate-limiting concurrent spawns with configurable concurrency capsdetachflag to allow spawned tasks to complete independently without blockingTests