Skip to content

feat: add shared ExecutionBudget consolidating runtime resource caps - #609

Merged
logbie merged 33 commits into
mainfrom
claude/shared-execution-budget-1rqfir
Jul 12, 2026
Merged

feat: add shared ExecutionBudget consolidating runtime resource caps#609
logbie merged 33 commits into
mainfrom
claude/shared-execution-budget-1rqfir

Conversation

@logbie

@logbie logbie commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

WFL enforced a dozen unrelated resource ceilings from a dozen unrelated places, and several audit-flagged resources had no ceiling at all. This PR replaces that scatter with one object — exec::budget::ExecutionBudget — that travels with a run through parsing, evaluation, pattern matching, web handling, and module loading, and owns every ceiling as one coherent mechanism.

What the budget now owns

Dimension Before
Deadline + cancellation Interpreter.max_duration + op_count throttle
Interpreter operations none (op counter only throttled clock reads)
Recursion depth unbounded in release (a debug_assert! only)
Import / include depth circular-detection only; otherwise unbounded
execute file depth lone const MAX_EXECUTE_FILE_DEPTH = 4
Pattern transitions lone const MAX_STEPS = 100_000
Pattern active states unbounded state fan-out
Source-file size unbounded
HTTP request body web_server_max_body_size
HTTP response body unbounded
Pending HTTP requests web_server_request_queue_bound
WebSocket queues + connections unbounded channels / registry

Design

  • src/exec/budget.rsExecutionBudget is Send + Sync (atomics only), so an Arc<ExecutionBudget> clones into the multi-threaded web transport (warp accept tasks, per-connection WebSocket tasks) without any Rc/RefCell crossing a thread boundary. The interpreter core stays !Send; this shares a small atomic-only object exactly like Arc<WflConfig> already is. BudgetLimits::from_config maps the existing .wflcfg keys plus nine new budget keys. BudgetExceeded keeps the historic timeout wording and ErrorKind::Timeout so existing timeout handling still matches; a new ErrorKind::ResourceLimit covers the rest.
  • Interpreter — dropped op_count/started/max_duration for budget: Arc<ExecutionBudget>; check_time charges an operation (deadline exempt inside main loop, cancellation always honored). Recursion, import, execute-file, and response-byte guards call the budget; request-body/queue bounds source from it; WebSocket channels are now bounded with try_send shed-on-Full, and connections are capped via an RAII guard.
  • Pattern VMMAX_STEPS removed; each match checks steps and (new) active states against the shared budget, plus cancellation. matches/find operators share the run budget.
  • Large-stack thread — WFL's async tree-walking interpreter costs ~1 MiB of debug stack per WFL call (an 8 MiB stack overflows near depth ~40), so main now runs the runtime on a dedicated 1 GiB stack. That makes max_call_depth (default 1000) a clean, catchable "Maximum call depth exceeded" error instead of a native SIGABRT, and lets programs recurse deeper than before.

Backward compatibility

The three pre-existing knobs (timeout, request body, request queue) keep their defaults and behavior exactly — the budget just sources their values. max_operations is off by default; every new ceiling defaults generously enough that no existing program trips it.

Testing

  • src/exec/budget.rs: 11 unit tests (ceiling/exemption/cancellation/deadline, depth >= vs pattern > semantics, byte caps, RAII guards, from_config, Send + Sync).
  • tests/execution_budget_test.rs: config parsing of all new keys + end-to-end (deep recursion → clean error, not stack overflow; configured ceiling honored; oversized source refused; shallow program still runs).
  • Existing suites unchanged: full lib tests (incl. test_timeout_forever_loop), web_queue_bound_test, websocket_test, execute_file_test, 107/107 TestPrograms/, and the web-driver tests all pass. cargo fmt --check and clippy --all-targets -- -D warnings are clean.

Docs

  • Docs/reference/configuration-reference.md: the nine new keys + an "Execution budget (resource limits)" section.
  • Docs/04-advanced-features/web-servers.md: response-size and WebSocket queue/connection limits.
  • Dev Diary: Dev diary/2026-07-12-shared-execution-budget.md.

Follow-ups (noted in the Dev Diary)

  • Stdlib pattern builtins (pattern_replace/split) run under a standalone budget with the same step/state ceilings; threading the run budget through their native signatures is a future refactor.
  • The outbound reqwest client still has no per-request timeout/in-flight cap (a separate audit item).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A


Generated by Claude Code


Open in Devin Review

Summary by CodeRabbit

  • New Features
    • Added a shared per-run execution budget that enforces interpreter execution, nested execute file, pattern matching, bounded source loading, and coordinated HTTP/WebSocket limits.
    • Added new configuration ceilings for operation counts, recursion/import/execute file depth, pattern steps/states, and source/HTTP/WebSocket sizes (queue/connection caps included).
  • Bug Fixes
    • Hardened budgeting across parse/typecheck/analyze and REPL, with consistent “budget exceeded” behavior and deterministic backpressure handling for HTTP and WebSocket.
  • Documentation
    • Updated the configuration reference and web-server limitations to document the new ceilings and enforcement behavior.

Replace a dozen isolated, per-subsystem resource ceilings (and several
that were simply unbounded) with one coherent ExecutionBudget object that
travels with a run through parsing, evaluation, pattern matching, web
handling, and module loading.

The budget owns every dimension:
- Deadline and cooperative cancellation (was Interpreter.max_duration/op_count)
- Interpreter operation ceiling (new, opt-in; default unlimited)
- Recursion and import depth (recursion was unguarded in release)
- execute-file depth (was MAX_EXECUTE_FILE_DEPTH)
- Pattern transitions and active states (was MAX_STEPS; states now bounded)
- Source, request-body, and response byte caps (source/response were unbounded)
- Pending HTTP requests (was web_server_request_queue_bound)
- WebSocket queue and connection limits (were unbounded channels/registry)

Design:
- src/exec/budget.rs: ExecutionBudget is Send+Sync (atomics only), so an
  Arc clones into the multi-threaded web transport without any Rc/RefCell
  crossing a thread boundary. The interpreter core stays !Send. BudgetLimits
  maps the existing .wflcfg keys plus nine new budget keys; BudgetExceeded
  keeps the historic timeout wording/ErrorKind so existing handling matches.
- The interpreter runs on a dedicated 1 GiB-stack thread so max_call_depth
  (default 1000) turns runaway recursion into a clean, catchable error
  instead of a native stack overflow (an 8 MiB stack overflows near depth 40).

Backward compatibility: the three pre-existing knobs keep their defaults
exactly; new ceilings default generously; max_operations is off by default.
All 515 lib tests, the new budget suite, and 107 TestPrograms pass.

Docs: configuration-reference (new keys + Execution budget section),
web-servers (response/WebSocket limits), and a Dev Diary entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
Copilot AI review requested due to automatic review settings July 12, 2026 06:19
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This change introduces a shared ExecutionBudget covering operation, depth, pattern, byte, HTTP, and WebSocket limits. Configuration, runtime enforcement, bounded transport, pattern APIs, CLI/REPL integration, documentation, and end-to-end tests are updated.

Changes

Shared execution budget

Layer / File(s) Summary
Budget configuration and documentation
src/config.rs, src/wfl_config/checker.rs, Docs/..., Cargo.toml, CLAUDE.md, Dev diary/...
Adds budget fields, defaults, validation, configuration-tool support, documentation, compatibility notes, and the Rust 1.88 minimum toolchain declaration.
Budget primitives and public API
src/exec/*, src/lib.rs, src/interpreter/error.rs
Adds immutable limits, typed errors, atomic accounting, cancellation and deadline checks, depth and byte checks, pattern meters, and RAII admission guards.
Interpreter and source enforcement
src/interpreter/mod.rs
Replaces local execution tracking with shared budget checks across calls, imports, source loading, HTTP handling, response delivery, WebSocket lifecycle, and nested execution.
Pattern VM budget propagation
src/pattern/*, src/stdlib/pattern.rs
Adds fallible budget-aware pattern APIs and enforces per-match steps, active states, cancellation, nested lookaround accounting, and runtime error mapping.
Runtime entrypoint and validation
src/main.rs, src/repl.rs, src/parser/mod.rs, src/typechecker/mod.rs, src/analyzer/*, tests/execution_budget_test.rs
Shares budgets across CLI, front-end, and REPL execution, bounds source reads and command lifetime, uses a large-stack runtime thread, and tests configuration and enforcement behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant ExecutionBudget
  participant Interpreter
  participant PatternVM
  participant WebTransport
  CLI->>ExecutionBudget: construct limits from configuration
  Interpreter->>ExecutionBudget: charge operations and acquire request capacity
  Interpreter->>PatternVM: execute pattern with shared budget
  PatternVM->>ExecutionBudget: charge steps and reserve active states
  WebTransport->>ExecutionBudget: reserve bytes and connections
  ExecutionBudget-->>Interpreter: return permit or typed budget error
Loading

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: introducing a shared ExecutionBudget for runtime resource limits.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/shared-execution-budget-1rqfir

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.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread src/pattern/vm.rs

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.

🔍 Pattern find/find_all silently swallow cancellation and budget errors

The PatternVM::find method at src/pattern/vm.rs:201-205 uses if let Ok(Some(result)), meaning any Err (including PatternError::Cancelled or StepLimitExceeded) is silently treated as 'no match at this position' and the loop tries the next position. The same applies to find_all at line 224. This is pre-existing behavior (the old code also swallowed StepLimitExceeded), but it's now more significant because the budget can inject cancellation errors. A cancelled pattern match will appear to return 'no match' rather than propagating the cancellation. The _with_budget variants on CompiledPattern inherit this behavior. The interpreter's matches operator (src/interpreter/mod.rs) calls matches_with_budget which also returns false on error via unwrap_or(false). This means a budget cancellation during pattern matching silently produces a false-negative rather than an error.

(Refers to lines 192-209)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copilot AI 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.

Pull request overview

This PR introduces a shared, cross-cutting exec::budget::ExecutionBudget to centralize and consistently enforce runtime resource ceilings across the interpreter, pattern VM, module loading, web handling, and CLI entry points, replacing multiple scattered limits and several previously-unbounded resources.

Changes:

  • Adds src/exec/budget.rs with BudgetLimits, ExecutionBudget, typed BudgetExceeded, and RAII guards for request/WS connection accounting.
  • Threads a shared Arc<ExecutionBudget> into the interpreter and pattern VM, adding enforcement for recursion/import/execute-file depth, pattern step/state caps, response bytes, and WebSocket queue/connection bounding.
  • Extends config + docs + tests to support and validate the new budget keys and end-to-end enforcement (notably deep recursion and source-size refusal).

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/execution_budget_test.rs Integration coverage for new .wflcfg budget keys and end-to-end enforcement (recursion + source-size).
src/pattern/vm.rs Replaces fixed VM step cap with shared budget checks; adds cancellation/state fan-out limiting and budget sharing for lookarounds.
src/pattern/mod.rs Adds pattern error variants and *_with_budget APIs so interpreter pattern ops share the run budget.
src/main.rs Runs runtime on a large-stack thread; enforces max_source_size before lex/parse.
src/lib.rs Exposes new exec module from the crate root.
src/interpreter/mod.rs Replaces timeout/opcount fields with shared ExecutionBudget; adds enforcement across recursion/import/execute-file/web response/WS limits.
src/interpreter/error.rs Adds ErrorKind::ResourceLimit and display prefixing.
src/exec/mod.rs Introduces exec module and re-exports budget types.
src/exec/budget.rs New core implementation for shared limits/accounting and unit tests.
src/config.rs Adds new config keys + parsing/validation helpers feeding the budget.
Docs/reference/configuration-reference.md Documents new keys and the execution-budget concept.
Docs/04-advanced-features/web-servers.md Documents response-size and WebSocket queue/connection limits as part of the shared budget.
Dev diary/2026-07-12-shared-execution-budget.md Dev diary entry describing the motivation, design, and follow-ups for the shared budget.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/pattern/vm.rs Outdated
Comment on lines +19 to +21
// The step ceiling and any deadline both surface as the historic
// step-limit error so existing ReDoS handling keeps matching.
_ => PatternError::StepLimitExceeded,
Comment thread src/pattern/vm.rs Outdated
Comment on lines +157 to +160
/// A standalone VM whose pattern ceilings match the historic ReDoS defaults
/// (`MAX_STEPS` = 100_000, plus an active-state cap). Used by
/// [`super::CompiledPattern`]'s convenience methods and stdlib pattern
/// builtins, which have no interpreter budget to share.
Comment thread src/main.rs Outdated
Comment on lines +770 to +773
// Enforce the shared source-size ceiling before doing any lexing/parsing.
// Uses the same ExecutionBudget the interpreter will run under, so the
// `max_source_size` .wflcfg knob governs every entry point (run, lint,
// analyze, dump) from one place.
| `web_socket_queue_bound` | integer ≥ 1 | `1024` | Max queued frames/events per WebSocket channel before shedding |
| `web_socket_max_connections` | integer ≥ 1 | `1024` | Max simultaneous live WebSocket connections |

### Execution budget (resource limits)
Comment thread src/pattern/vm.rs
Comment on lines 261 to 262
let mut next_states = Vec::new();

Comment thread src/pattern/vm.rs
Comment on lines 312 to 313
let mut next_states = Vec::new();

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1fee3375b0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main.rs Outdated
Comment on lines +774 to +775
if let Err(exceeded) =
wfl::exec::budget::ExecutionBudget::from_config(&config).check_source_bytes(input.len())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce source caps before loading sources

With max_source_size set, this check runs only after fs::read_to_string has already read the entire top-level file into memory, and rg check_source_bytes shows no equivalent check in the load module, include, or execute file read paths. In those scenarios, a huge main file can still exhaust memory before the limit fires, and a tiny main file can parse an oversized nested WFL source without tripping the new budget at all. Load the config before reading and enforce the byte cap for every WFL source before or during the read.

Useful? React with 👍 / 👎.

Comment thread src/interpreter/mod.rs Outdated
op_count: Cell::new(0),
started: Instant::now(),
max_duration: Duration::from_secs(config.timeout_seconds),
budget: Arc::new(ExecutionBudget::from_config(&config)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Share the execution budget with child interpreters

When an execute file statement runs, it later creates the nested interpreter with Interpreter::with_config(Arc::clone(&self.config)), and this constructor always installs a fresh ExecutionBudget here. In that scenario the operation counter, deadline start time, and cancellation state reset for each executed file, so code can split work across execute file calls and exceed the configured run budget without being stopped. Add a nested-execution path that clones the parent Arc<ExecutionBudget> instead of rebuilding it from config.

Useful? React with 👍 / 👎.

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main.rs (1)

766-780: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Source-size ceiling is checked after the whole file is already read into memory.

fs::read_to_string (line 766) loads the entire file before check_source_bytes (774-779) ever runs, so a max_source_size set specifically to bound memory/IO cost doesn't actually prevent an oversized file from being fully read first — it only rejects afterward. Check the file's on-disk size via fs::metadata before reading.

🛡️ Proposed fix: check size before reading the file
-    let input = fs::read_to_string(&file_path)?;
     let script_dir = Path::new(&file_path).parent().unwrap_or(Path::new("."));
     let config = config::load_config(script_dir);

-    // Enforce the shared source-size ceiling before doing any lexing/parsing.
+    // Enforce the shared source-size ceiling using the file's on-disk size,
+    // before reading its contents into memory.
     // Uses the same ExecutionBudget the interpreter will run under, so the
     // `max_source_size` .wflcfg knob governs every entry point (run, lint,
     // analyze, dump) from one place.
+    let source_len = fs::metadata(&file_path)?.len() as usize;
     if let Err(exceeded) =
-        wfl::exec::budget::ExecutionBudget::from_config(&config).check_source_bytes(input.len())
+        wfl::exec::budget::ExecutionBudget::from_config(&config).check_source_bytes(source_len)
     {
         eprintln!("Error: {}", exceeded.message());
         process::exit(2);
     }
+
+    let input = fs::read_to_string(&file_path)?;
🤖 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 `@src/main.rs` around lines 766 - 780, Update the file-loading flow around
`fs::read_to_string` to call `fs::metadata` first, compare the file’s on-disk
length against the configured `ExecutionBudget` source limit, and reject
oversized files before reading them. Preserve the existing error message and
exit behavior, then read the file only after the preflight size check succeeds.
🧹 Nitpick comments (3)
src/config.rs (1)

767-780: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

web_server_max_response_size can reuse set_positive_usize.

This match arm duplicates the logic of the set_positive_usize helper defined just above. It could be replaced with a one-liner call, consistent with the other budget keys at lines 797–838.

♻️ Proposed refactor
-                "web_server_max_response_size" => match value.parse::<usize>() {
-                    Ok(0) | Err(_) => log::warn!(
-                        "Invalid web_server_max_response_size '{}' in {}: expected a positive integer",
-                        value,
-                        file.display()
-                    ),
-                    Ok(size) => {
-                        config.web_server_max_response_size = size;
-                        log::debug!(
-                            "Loaded web_server_max_response_size: {size} from {}",
-                            file.display()
-                        );
-                    }
-                },
+                "web_server_max_response_size" => set_positive_usize(
+                    &mut config.web_server_max_response_size,
+                    "web_server_max_response_size",
+                    value,
+                    file,
+                ),
🤖 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 `@src/config.rs` around lines 767 - 780, Replace the duplicated parsing and
validation match for web_server_max_response_size with a call to the existing
set_positive_usize helper, passing the parsed value, configuration field, key
name, and file context consistently with the neighboring budget-key arms.
Preserve the current positive-integer validation and assignment behavior.
src/exec/budget.rs (1)

806-818: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider testing more from_config field mappings.

from_config_maps_existing_keys only asserts 3 of the 14 BudgetLimits fields. The new budget-specific fields (max_call_depth, max_import_depth, max_pattern_steps, max_source_bytes, max_response_bytes, max_ws_queue, max_ws_connections, max_operations) are simple field-to-field copies, but a regression in from_config could silently pass this test.

♻️ Proposed additional assertions
     #[test]
     fn from_config_maps_existing_keys() {
         let config = WflConfig {
             timeout_seconds: 42,
             web_server_max_body_size: 4096,
             web_server_request_queue_bound: 7,
+            max_operations: Some(99),
+            max_call_depth: 500,
+            max_import_depth: 30,
+            max_execute_file_depth: 2,
+            max_pattern_steps: 50_000,
+            max_pattern_states: 5_000,
+            max_source_size: 1024,
+            web_server_max_response_size: 2048,
+            web_socket_queue_bound: 64,
+            web_socket_max_connections: 32,
             ..Default::default()
         };
         let limits = BudgetLimits::from_config(&config);
         assert_eq!(limits.max_duration, Some(Duration::from_secs(42)));
         assert_eq!(limits.max_request_body_bytes, 4096);
         assert_eq!(limits.max_pending_requests, 7);
+        assert_eq!(limits.max_operations, Some(99));
+        assert_eq!(limits.max_call_depth, 500);
+        assert_eq!(limits.max_import_depth, 30);
+        assert_eq!(limits.max_execute_file_depth, 2);
+        assert_eq!(limits.max_pattern_steps, 50_000);
+        assert_eq!(limits.max_pattern_states, 5_000);
+        assert_eq!(limits.max_source_bytes, 1024);
+        assert_eq!(limits.max_response_bytes, 2048);
+        assert_eq!(limits.max_ws_queue, 64);
+        assert_eq!(limits.max_ws_connections, 32);
     }
🤖 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 `@src/exec/budget.rs` around lines 806 - 818, Expand the existing
from_config_maps_existing_keys test to configure and assert the eight untested
budget-specific mappings: max_call_depth, max_import_depth, max_pattern_steps,
max_source_bytes, max_response_bytes, max_ws_queue, max_ws_connections, and
max_operations. Verify each resulting BudgetLimits field matches its
corresponding WflConfig value while preserving the existing assertions.
tests/execution_budget_test.rs (1)

28-40: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider bounding run_with_cfg's subprocess with a timeout.

Command::output() has no timeout, so if budget enforcement ever regresses into a real hang instead of a fast depth/size error — exactly the class of bug these tests exist to catch — the test run blocks indefinitely instead of failing fast.

Want me to wire in a bounded wait (e.g. via the wait-timeout crate) around the spawned wfl process in run_with_cfg?

Also applies to: 112-166

🤖 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 `@tests/execution_budget_test.rs` around lines 28 - 40, Update the test helper
run_with_cfg to spawn the wfl process and enforce a finite timeout while
waiting, rather than calling Command::output() without a bound. On timeout,
terminate the child and fail the test clearly; preserve the existing
configuration, script setup, and captured Output behavior for processes that
finish normally, including the additional call sites noted in the comment.
🤖 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 `@src/exec/budget.rs`:
- Around line 340-415: Replace the `if let ... && ...` let-chain syntax in
`check_deadline` and `charge_operation` with Rust 1.85-compatible nested
conditionals, preserving the existing deadline, operation-limit, and
cancellation behavior.

In `@src/interpreter/mod.rs`:
- Around line 2194-2209: Update budget_error so it does not clear call_stack for
catchable recursion-limit ResourceLimit errors, preserving active frames when
the error is handled by try/when. Retain stack clearing for non-catchable budget
failures and keep the existing in_count_loop cleanup and error-kind behavior
unchanged.

---

Outside diff comments:
In `@src/main.rs`:
- Around line 766-780: Update the file-loading flow around `fs::read_to_string`
to call `fs::metadata` first, compare the file’s on-disk length against the
configured `ExecutionBudget` source limit, and reject oversized files before
reading them. Preserve the existing error message and exit behavior, then read
the file only after the preflight size check succeeds.

---

Nitpick comments:
In `@src/config.rs`:
- Around line 767-780: Replace the duplicated parsing and validation match for
web_server_max_response_size with a call to the existing set_positive_usize
helper, passing the parsed value, configuration field, key name, and file
context consistently with the neighboring budget-key arms. Preserve the current
positive-integer validation and assignment behavior.

In `@src/exec/budget.rs`:
- Around line 806-818: Expand the existing from_config_maps_existing_keys test
to configure and assert the eight untested budget-specific mappings:
max_call_depth, max_import_depth, max_pattern_steps, max_source_bytes,
max_response_bytes, max_ws_queue, max_ws_connections, and max_operations. Verify
each resulting BudgetLimits field matches its corresponding WflConfig value
while preserving the existing assertions.

In `@tests/execution_budget_test.rs`:
- Around line 28-40: Update the test helper run_with_cfg to spawn the wfl
process and enforce a finite timeout while waiting, rather than calling
Command::output() without a bound. On timeout, terminate the child and fail the
test clearly; preserve the existing configuration, script setup, and captured
Output behavior for processes that finish normally, including the additional
call sites noted in the comment.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0686da01-1a41-43f5-9fea-b827d19c0516

📥 Commits

Reviewing files that changed from the base of the PR and between d293a1d and 1fee337.

📒 Files selected for processing (13)
  • Dev diary/2026-07-12-shared-execution-budget.md
  • Docs/04-advanced-features/web-servers.md
  • Docs/reference/configuration-reference.md
  • src/config.rs
  • src/exec/budget.rs
  • src/exec/mod.rs
  • src/interpreter/error.rs
  • src/interpreter/mod.rs
  • src/lib.rs
  • src/main.rs
  • src/pattern/mod.rs
  • src/pattern/vm.rs
  • tests/execution_budget_test.rs

Comment thread src/exec/budget.rs
Comment thread src/interpreter/mod.rs
…s, catchable-error stack

Follow-up fixes from automated PR review on #609:

- Share the ExecutionBudget with the `execute file` child interpreter (clone the
  parent Arc) so the deadline, operation ceiling, and cancellation span the
  whole run instead of resetting per executed file.
- Enforce max_source_size on nested sources (load module / include / execute
  file) via file metadata *before* reading; the CLI top-level check now also
  reads metadata first, so oversized sources are refused without allocating.
- budget_error only force-clears the call stack for the terminal deadline; a
  catchable ResourceLimit (e.g. the recursion ceiling) leaves the stack for
  call_function to unwind, so the depth counter stays correct after a caught
  recursion error under try/when.
- Pattern VM checks the active-state ceiling after each expansion (fail fast)
  rather than only once the next generation is fully built.
- Doc/comment accuracy: PatternVM::new no longer references the removed
  MAX_STEPS; the VM error-map comment no longer implies deadline sampling; the
  CLI comment says "same config" not "same budget instance"; and the duplicate
  "Execution budget (resource limits)" doc heading was renamed for a unique
  anchor.

New regression tests: nested_execute_file_source_is_size_checked and
execute_file_shares_the_parent_operation_budget. Full lib suite, budget suite,
execute-file suite, and 107 TestPrograms pass; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
Copilot AI review requested due to automatic review settings July 12, 2026 07:43

@logbie logbie left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deep review — changes required before merge

Reviewed head 1fee3375b04511d76e2366f39a9dbed98ce6bcb0 against base d293a1dec9f23324035569f0b373e1b257535334: all 13 changed files, affected call sites, existing review threads, configuration tooling, and the HTTP/WebSocket/pattern execution paths. GitHub CI, WFL Config Lint, and the review workflow are green, but the security boundaries below are not covered by those checks.

This is submitted as a COMMENT review only because the authenticated logbie account is also the PR author. Treat the P1 findings as blocking.

P1 — blocking

  1. The recursion guard is bypassable after a caught limit error. budget_error() clears call_stack while the native recursive frames remain live. Since a general when error can catch ResourceLimit, the handler can recurse again from logical depth zero, stack another full quota on the existing frames, and still reach a native stack abort. The existing thread on src/interpreter/mod.rs:2194-2200 understates the impact. Enforcement depth needs RAII/live-frame accounting separate from diagnostic stack cleanup.

  2. Pattern limits do not count pattern transitions. The counter advances once per outer NFA breadth round, while step() may execute an arbitrary instruction chain; the private negative-lookahead NFA loop performs no step, cancellation, or active-state checks; and every nested lookaround VM starts its local counter at zero. Registered pattern_matches/pattern_find/pattern_find_all and pattern split also use the standalone default budget. A configured low limit therefore does not bound the work it claims to bound.

  3. HTTP request bytes and pending requests remain OOM paths. A chunked body has no Content-Length, so body::bytes() buffers it fully before the post-buffer check. Separately, the shared RequestGuard is unused: each listener has its own semaphore, its permit is released immediately after enqueue, and a dequeued request can wait forever in pending_responses. Repeated requests that are dequeued but never answered grow route tasks/map entries without bound.

  4. There is not one budget for one run. The CLI creates and discards a temporary budget for the top-level size check, then lexes/parses/analyzes before the real interpreter budget starts. execute file constructs another fresh budget. The interpreter budget is private and has no cancellation handle or caller-supplied constructor; repository usage of cancel() is limited to a unit test. This contradicts the stated parsing, deadline, cancellation, and child-execution coverage.

  5. Source limits still do not protect source loading. The top-level file is fully allocated before its check, and load module, include, execute file, and the REPL do not apply max_source_size before lexing. The existing source-limit thread is valid; use one bounded source loader for every entry point.

P2 — must resolve or explicitly narrow the PR contract

  • The response cap clones/formats the entire payload before checking it and can leave an oversized request pending when the error is caught.
  • The configuration checker/fixer does not know any new budget keys; --configFix comments them out as unknown, potentially reverting max_operations to unlimited.
  • Bounded WebSocket data queues now carry non-droppable lifecycle/control traffic. A full queue can lose Connect/Disconnect, and close server can drop its Close frame while reporting success.
  • The 1 GiB stack is reserved before argument routing, so even --help/config/package commands can fail under a smaller address-space limit. It also does not protect public Interpreter users on ordinary threads, despite the default depth of 1000 and the PR's own note that an 8 MiB debug stack overflows near depth 40.
  • Resolve the open pattern error-propagation/deadline/state-allocation threads and either restore the documented Rust 1.85 compatibility or raise/test the MSRV.

Tests needed before merge

Add adversarial tests for: catch-and-recurse after the call-depth error; negative/nested lookaround under limits of 1; every WFL pattern entry point using configured limits; chunked oversized bodies; multiple listeners plus dequeued/unanswered requests; nested/REPL source limits; cancellation through an active child; oversized response cleanup; full WebSocket queues during disconnect/server close; and config check/fix round-tripping every new key.

I could not rerun Rust locally because this review environment has no cargo or rustc; git diff --check passed, and the GitHub workflow results on this exact head are green.

Comment thread src/config.rs
);
}
},
"max_operations" => match value.parse::<u64>() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Register every new key with ConfigChecker. parse_config_text accepts the new budget settings, but src/wfl_config/checker.rs::ConfigChecker::new still has no entries for them. --configCheck therefore reports valid settings as unknown, and --configFix comments those lines out; for max_operations, that silently changes a configured ceiling back to unlimited. Add the exact types/ranges/defaults to expected_settings and a check/fix round-trip test covering all new keys.

Comment thread src/pattern/vm.rs Outdated
return Err(PatternError::StepLimitExceeded);
}
self.budget
.check_pattern_steps(self.step_count)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] Count actual transitions with one meter shared by nested matching. This checkpoint runs once per outer states wave, not once per VM transition: step() can execute an arbitrary Char/Literal/Jump/capture chain before returning (an epsilon Jump cycle never returns), and the negative-lookahead loop at 610–648 repeatedly steps and extends states without any budget/cancellation check. Nested lookaround VMs also reset step_count to zero, so sharing this Arc shares only the limit, not consumption. Charge inside instruction dispatch and before every fan-out, and keep the counter shared for the whole match including lookarounds.

Comment thread src/interpreter/mod.rs
// (Phase 0, PR-0c) so a flood of accepted-but-unhandled requests
// sheds with 503 instead of growing memory without bound.
let queue_bound = self.config.web_server_request_queue_bound.max(1);
let queue_bound = self.budget.max_pending_requests();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] Enforce this as a shared accepted-but-unfinished request cap. Each listen still creates its own queue/semaphore, the permit is dropped immediately after enqueue, and ExecutionBudget::try_acquire_request() is never used outside unit tests. If WFL dequeues requests but does not respond, each route task waits forever and each sender remains in pending_responses, while the queue keeps admitting more work. Acquire a shared budget guard before body buffering and retain it until response, disconnect, timeout, channel failure, or shutdown.

Comment thread src/interpreter/mod.rs
// (default 1 MB).
let request_sender_clone = request_sender.clone();
let max_body_size = self.config.web_server_max_body_size;
let max_body_size = self.budget.max_request_body_bytes();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] Apply the body limit while reading, not after body::bytes(). The early check only sees an optional Content-Length; a chunked client supplies none, so Warp buffers the complete attacker-controlled body before the later body.len() check. That can OOM despite a tiny configured cap. Consume the body stream with a running byte count and abort as soon as this budget limit is exceeded.

Comment thread src/interpreter/mod.rs
// Enforce the shared response-body ceiling before handing the
// payload to the transport, so a handler cannot emit an
// unbounded response.
if let Err(exceeded) = self.budget.check_response_bytes(content_bytes.len()) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Check before cloning/formatting, and complete or release the pending request on failure. content_bytes has already duplicated the entire Text/Binary (or formatted an arbitrarily large object) before this check, so the cap does not bound peak allocation. Returning here also happens before pending_responses.remove; if WFL catches the resource error and continues, the client and sender can remain pending indefinitely. Validate borrowed lengths before allocation and make request completion/cleanup RAII-backed on every error path.

Comment thread src/interpreter/mod.rs
for id in ids {
if let Some(tx) = map.remove(&id) {
let _ = tx.send(WsOutbound::Close);
let _ = tx.try_send(WsOutbound::Close);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Do not put the non-droppable Close control message on the saturated data queue. If try_send returns Full, this code discards Close and removes the only sender from the registry anyway. The writer drains and exits when the channel closes, but the reader task can remain alive waiting on the peer, retaining the connection guard after close server reported success. Use a separate cancellation/control path (or retain/abort the per-connection task) so server close is guaranteed.

Comment thread src/main.rs Outdated
/// turn runaway recursion into a clean, catchable error instead of the OS
/// killing the whole process with a stack overflow. 1 GiB is reserved
/// virtually and committed lazily, so normal programs pay nothing for it.
const INTERPRETER_STACK_SIZE: usize = 1024 * 1024 * 1024;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Avoid making a 1 GiB reservation a prerequisite for every CLI operation. This thread is spawned before arguments are inspected, so even wfl --help, config commands, and package commands fail to start under an address-space limit below 1 GiB or in a 32-bit process. The workaround also does not protect public Interpreter callers on normal stacks, where the documented default depth 1000 is far above the PR's stated ~40-frame debug overflow point. Defer any special runner until actual interpretation and make recursion safety independent of a caller's stack reservation.

Copilot AI 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.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

src/interpreter/mod.rs:282

  • The writer task only sends a close frame when it receives WsOutbound::Close. If the bounded channel is full during shutdown, try_send(WsOutbound::Close) can fail and dropping the sender will just end the writer loop without sending a close frame. Consider best-effort sending a close when the channel closes (recv returns None) to preserve the previous shutdown semantics.
    // Writer task: drains queued frames to the socket until the channel closes
    // or the peer goes away.
    let writer = tokio::spawn(async move {
        while let Some(out) = out_rx.recv().await {
            match out {
                WsOutbound::Text(text) => {
                    if ws_tx.send(warp::ws::Message::text(text)).await.is_err() {
                        break;
                    }
                }
                WsOutbound::Close => {
                    let _ = ws_tx.send(warp::ws::Message::close()).await;
                    let _ = ws_tx.flush().await;
                    break;
                }
            }
        }
    });

Comment on lines +200 to +206
fs::write(
&main,
format!(
"execute file at \"{}\" and read output as out\n",
big.display()
),
)
Comment on lines +265 to +271
let combined = run(
"program.wfl",
format!(
"{loop_body}execute file at \"{}\" and read output as out\ndisplay out\n",
child.display()
),
);
Comment thread src/main.rs Outdated
Comment on lines +776 to +781
if let Ok(meta) = fs::metadata(&file_path)
&& let Err(exceeded) = source_budget.check_source_bytes(meta.len() as usize)
{
eprintln!("Error: {}", exceeded.message());
process::exit(2);
}
Comment thread src/interpreter/mod.rs Outdated
Comment on lines 2230 to 2234
if let Ok(meta) = tokio::fs::metadata(path).await
&& let Err(exceeded) = self.budget.check_source_bytes(meta.len() as usize)
{
return Err(self.budget_error(exceeded, line, column));
}

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
Docs/reference/configuration-reference.md (1)

530-536: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align queue-overflow documentation with actual frame handling.

Line 6692 and Line 6730 discard failed outbound try_send calls without logging, so full frame queues do not emit the documented warning. Either log Full consistently or remove that promise.

As per coding guidelines, “Keep user documentation current and ship documentation with user-facing feature or behavior changes.”

🤖 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 `@Docs/reference/configuration-reference.md` around lines 530 - 536, Align the
web_socket_queue_bound documentation with the outbound frame handling by either
adding warning logs for failed try_send calls when the queue is full, or
removing the documented warning promise. Update the relevant outbound frame-send
logic and the configuration reference consistently, preserving the existing
queue-bound behavior.

Source: Coding guidelines

tests/execution_budget_test.rs (1)

112-146: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a catch-and-continue recursion-limit regression.

These tests cover uncaught depth errors only. Add a try/when case that catches a resource-limit recursion error and then recurses again, covering the call-stack preservation fixed in budget_error.

As per coding guidelines, “TDD is mandatory: write a failing test before implementing any feature or bug fix.”

🤖 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 `@tests/execution_budget_test.rs` around lines 112 - 146, Extend the
execution-budget tests with a catch-and-continue case that uses try/when to
catch the recursion resource-limit error, then recursively invokes the program
again and verifies execution continues without stack corruption. Anchor the
regression test near deep_recursion_is_a_clean_error_not_a_stack_overflow and
configured_call_depth_is_honored, and make it fail before the corresponding
budget_error fix is implemented.

Source: Coding guidelines

src/interpreter/mod.rs (1)

8705-8707: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Propagate pattern budget exhaustion instead of collapsing it into a miss. src/interpreter/mod.rs:8705-8707, 8744-8745 The budget-aware pattern helpers still hide PatternError behind bool/Option, so step/state exhaustion is indistinguishable from a normal non-match. Return a Result here and route the error through budget_error so pattern limits remain catchable at runtime.

🤖 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 `@src/interpreter/mod.rs` around lines 8705 - 8707, Update the pattern-matching
flow around compiled_pattern.matches_with_budget and the corresponding logic
near the later match handling to preserve PatternError as a Result instead of
converting exhaustion into false or None. Propagate failures through
budget_error so step/state limits remain catchable at runtime, while retaining
normal non-match behavior for successful evaluations.
🤖 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 `@src/interpreter/mod.rs`:
- Around line 2220-2235: The enforce_source_size check only validates metadata
and does not bound the subsequent read. Update the source-loading paths that use
enforce_source_size to read at most max_source_size plus one byte, then raise
budget_error when the read exceeds max_source_size; preserve the existing line
and column context and avoid relying solely on metadata, including for special
files.

---

Outside diff comments:
In `@Docs/reference/configuration-reference.md`:
- Around line 530-536: Align the web_socket_queue_bound documentation with the
outbound frame handling by either adding warning logs for failed try_send calls
when the queue is full, or removing the documented warning promise. Update the
relevant outbound frame-send logic and the configuration reference consistently,
preserving the existing queue-bound behavior.

In `@src/interpreter/mod.rs`:
- Around line 8705-8707: Update the pattern-matching flow around
compiled_pattern.matches_with_budget and the corresponding logic near the later
match handling to preserve PatternError as a Result instead of converting
exhaustion into false or None. Propagate failures through budget_error so
step/state limits remain catchable at runtime, while retaining normal non-match
behavior for successful evaluations.

In `@tests/execution_budget_test.rs`:
- Around line 112-146: Extend the execution-budget tests with a
catch-and-continue case that uses try/when to catch the recursion resource-limit
error, then recursively invokes the program again and verifies execution
continues without stack corruption. Anchor the regression test near
deep_recursion_is_a_clean_error_not_a_stack_overflow and
configured_call_depth_is_honored, and make it fail before the corresponding
budget_error fix is implemented.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3647a3fa-2eb5-45d0-955d-1146a1fd190f

📥 Commits

Reviewing files that changed from the base of the PR and between 1fee337 and 953fcd6.

📒 Files selected for processing (6)
  • Dev diary/2026-07-12-shared-execution-budget.md
  • Docs/reference/configuration-reference.md
  • src/interpreter/mod.rs
  • src/main.rs
  • src/pattern/vm.rs
  • tests/execution_budget_test.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • Dev diary/2026-07-12-shared-execution-budget.md
  • src/main.rs
  • src/pattern/vm.rs

Comment thread src/interpreter/mod.rs Outdated
…ows CI)

WFL treats backslash as an escape character in string literals, so embedding a
Windows path (from Path::display()) into an `execute file at "..."` statement
broke parsing on windows-latest. Normalize embedded paths to forward slashes,
which the runtime accepts on every platform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
Copilot AI review requested due to automatic review settings July 12, 2026 08:13

Copilot AI 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.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Comment thread src/interpreter/mod.rs Outdated
Comment on lines 3773 to 3777
// 4. Read file content (source-size ceiling first)
self.enforce_source_size(&resolved_path, *line, *column)
.await?;
let content = tokio::fs::read_to_string(&resolved_path)
.await
Comment thread src/interpreter/mod.rs Outdated
Comment on lines 3942 to 3946
// 4. Read file content (source-size ceiling first)
self.enforce_source_size(&resolved_path, *line, *column)
.await?;
let content = tokio::fs::read_to_string(&resolved_path)
.await
Comment thread src/interpreter/mod.rs Outdated
Comment on lines 6894 to 6898
self.enforce_source_size(&resolved_path, *line, *column)
.await?;
let content = tokio::fs::read_to_string(&resolved_path)
.await
.map_err(map_io_error)?;
Comment thread src/exec/budget.rs Outdated
Comment on lines +386 to +397
// `fetch_add` returns the previous value; use it as this op's index so
// the very first op (index 0) is a sample point, matching the old code.
let index = self.operations.fetch_add(1, Ordering::Relaxed);
let sample = index & (CLOCK_SAMPLE_STRIDE - 1) == 0;

if sample && self.cancelled.load(Ordering::Relaxed) {
return Err(BudgetExceeded::Cancelled);
}

if !enforce_limits {
return Ok(());
}
…oint

Addresses maintainer P1-4, P1-5 and related Copilot findings.

- One ExecutionBudget per run: main.rs builds a single budget from the
  timeout-capped run_config up front and reuses it for the pre-parse source
  check AND the interpreter (new Interpreter::with_config_and_budget +
  Interpreter::budget() cancellation handle). Its deadline clock now covers
  lexing/parsing/analysis/interpretation, and execute-file already shares it.
- Bounded source loader: read at most max_source_size+1 bytes (CLI, load
  module, include, execute file, and now the REPL), so an oversized source is
  refused without allocating it — even when metadata is unavailable, stale, or
  reports 0 (special files). Replaces the metadata-only enforce_source_size.
- u64->usize source length converted safely (overflow = too large).
- charge_operation(false) (main-loop exemption) no longer increments the
  operation counter, so exempt work can't trigger a post-loop Operations breach;
  cancellation is still honored every call.
- Large interpreter stack is no longer a hard prerequisite: --help/--version run
  on the normal stack, and a failed large-stack reservation (tight RLIMIT_AS /
  32-bit) falls back to the default stack instead of refusing to start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
Copilot AI review requested due to automatic review settings July 12, 2026 08:28

Copilot AI 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.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Comment thread src/pattern/mod.rs
Comment on lines +292 to +296
budget: &std::sync::Arc<ExecutionBudget>,
) -> bool {
let mut vm = PatternVM::with_budget(std::sync::Arc::clone(budget));
vm.execute(&self.program, text).unwrap_or(false)
}
Comment thread src/interpreter/mod.rs
Comment on lines +2223 to +2237
fn budget_error(&self, exceeded: BudgetExceeded, line: usize, column: usize) -> RuntimeError {
if *self.in_count_loop.borrow() {
*self.in_count_loop.borrow_mut() = false;
*self.current_count.borrow_mut() = None;
}

let kind = match exceeded {
BudgetExceeded::Deadline { .. } => {
self.call_stack.borrow_mut().clear();
ErrorKind::Timeout
}
_ => ErrorKind::ResourceLimit,
};
RuntimeError::with_kind(exceeded.message(), line, column, kind)
}
…e on catch

Addresses the maintainer's P1-1 (recursion guard bypassable after a caught
limit) and Copilot's count-loop-state finding.

- Enforcement recursion depth now lives in a dedicated `call_depth: Cell<usize>`
  incremented/decremented by an RAII CallDepthGuard in call_function, separate
  from the diagnostic `call_stack` (which may be force-cleared). The guard
  restores depth on every unwind, so a caught ResourceLimit can never leave the
  depth under-counted and pile onto still-live native frames.
- budget_error no longer mutates any interpreter state. Every budget breach is
  catchable by a general try/when, so the call stack, count-loop flags, and
  recursion depth must unwind naturally; force-clearing them (the historic
  timeout behavior) corrupted an enclosing count loop and under-counted depth.
- interpret() resets in_count_loop/current_count/call_depth up front so an
  uncaught terminal breach can't leak stale state into a reused interpreter
  (REPL).

Regression test catching_a_recursion_limit_leaves_a_consistent_interpreter:
a count loop survives 3 caught recursion errors, `count` stays readable, and
re-recursing stays bounded (no stack overflow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
Copilot AI review requested due to automatic review settings July 12, 2026 08:36
claude added 2 commits July 12, 2026 08:59
…dget

Addresses the maintainer's P1-2 and the pattern error-swallowing findings.

- Transitions are now charged per *instruction* inside step()'s dispatch loop
  (and per negative-lookahead iteration), so an epsilon-Jump cycle or a lookaround
  instruction chain is bounded — not just the outer NFA waves.
- ONE shared meter (ExecutionBudget::pattern_steps, reset once per top-level op)
  is charged by nested lookaround/lookbehind VMs too, so their work counts
  against the same budget instead of resetting to zero. execute/find/find_all no
  longer reset; CompiledPattern's top-level methods reset once.
- Budget breaches PROPAGATE instead of collapsing into a non-match: the VM's
  find/find_all return Result, CompiledPattern's *_with_budget return Result, and
  the interpreter's matches/find operators plus the stdlib pattern builtins
  (pattern_matches/find/find_all/split) surface a catchable ResourceLimit error.
- A thread-local "current budget" (set for the run in interpret()) lets the
  stdlib pattern builtins — whose native signature has no budget parameter —
  honour the run's configured max_pattern_steps/max_pattern_states.
- max_pattern_steps default raised to 5_000_000 to suit per-instruction
  granularity (was a per-wave 100_000); ordinary patterns clear it easily.
- Active-state fan-out is checked after each expansion, including in the
  negative-lookahead loop.

Tests: pattern_step_limit_is_enforced_and_propagated / _is_catchable /
patterns_run_normally_under_default_budget; budget unit tests for the shared
meter and the current-budget scope guard. 107 TestPrograms + full lib pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
- ConfigChecker::new now knows every ExecutionBudget key (plus the previously
  unregistered web_server_request_queue_bound), so `--configCheck` accepts them
  and `--configFix` no longer strips them — which for max_operations would have
  silently reverted a configured ceiling back to unlimited. Round-trip test
  test_budget_keys_are_known_and_survive_fix covers all new keys.
- Record the real MSRV: the codebase uses `let`-chains (stable in Rust 1.88), so
  Cargo.toml gains rust-version = "1.88" and CLAUDE.md's stale "1.75+" is
  corrected, resolving the reviewer's MSRV-vs-let-chains concern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/repl.rs (1)

40-42: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Reset the REPL timeout per command
src/repl.rs:40-42 keeps one Interpreter/ExecutionBudget alive for the whole session, so timeout_seconds starts at REPL startup. After that wall-clock window expires, later commands can trip BudgetExceeded::Deadline on their first sampled op. Give each command a fresh budget, or make the REPL use an unlimited budget.

🤖 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 `@src/repl.rs` around lines 40 - 42, Update the REPL command execution flow
around new and its shared Interpreter so each command receives a fresh timeout
budget based on WflConfig.timeout_seconds, or configure the REPL interpreter
with an unlimited budget. Ensure later commands are not rejected because the
session-start deadline expired.
🤖 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.

Outside diff comments:
In `@src/repl.rs`:
- Around line 40-42: Update the REPL command execution flow around new and its
shared Interpreter so each command receives a fresh timeout budget based on
WflConfig.timeout_seconds, or configure the REPL interpreter with an unlimited
budget. Ensure later commands are not rejected because the session-start
deadline expired.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1a0be47c-013b-4ffc-99a1-a4ba23c01bbd

📥 Commits

Reviewing files that changed from the base of the PR and between 953fcd6 and ccf2179.

📒 Files selected for processing (9)
  • src/config.rs
  • src/exec/budget.rs
  • src/interpreter/mod.rs
  • src/main.rs
  • src/pattern/mod.rs
  • src/pattern/vm.rs
  • src/repl.rs
  • src/stdlib/pattern.rs
  • tests/execution_budget_test.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/config.rs
  • src/pattern/mod.rs

@logbie

logbie commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: now fully green — no maintainer action needed on the earlier red run.

Logging the diagnosis here so it's on the record. The previous run (29184686667, sha 953fcd6) failed on one job only — Integration Tests (windows-latest) — with two failures in the new tests/execution_budget_test.rs:

test nested_execute_file_source_is_size_checked ... FAILED
test execute_file_shares_the_parent_operation_budget ... FAILED
Lexing error at position 16: unexpected input `"C:\Users\RUNNER~1\AppData\Local\Temp\...\big.wfl"`

Root cause was platform-specific, not a bug in the budget itself: both tests embedded a tempfile path directly into generated WFL source, and on Windows that path is backslash-separated. WFL treats \ as an escape character inside a string literal, so the generated execute file at "C:\Users\..." failed to lex — the budget code under test was never reached. Linux passed precisely because its paths have no backslashes.

You've already fixed it in db3fff0 ("use forward-slash paths in execute-file budget tests") by routing both call sites through the wfl_path() helper, which does .replace('\\', "/"). Run 29186719943 confirms it: Check formatting, Build/Test/Clippy, Integration Tests (ubuntu + windows), Run WFL Programs (ubuntu + windows), Database Tests, config-lint, CodeQL — all green.

Worth keeping the wfl_path() helper as the standing convention for any future test that interpolates a filesystem path into WFL source; this failure mode will otherwise recur every time and only ever show up on the Windows leg.

This PR is green and awaiting review.

Posted by the WFL repo warden (automated triage pass).

claude added 2 commits July 12, 2026 09:20
… WS close

Addresses maintainer P1-3 and the remaining P2 web/REPL findings.

- Streaming body limit: the request-body ceiling is enforced while the body
  streams in (warp::body::stream + read_body_capped), so a chunked body with no
  Content-Length is bounded too — an oversized body gets 413 without being fully
  buffered. The Content-Length fast-path 413 stays for well-behaved clients.
- Global in-flight cap: admission now uses the shared ExecutionBudget
  RequestGuard, so the accepted-request cap is enforced across all listeners
  (not per-server). The guard is acquired before the body is read and held until
  the handler responds, the client disconnects, or the request times out.
- Per-request timeout: a dequeued request that is never answered is shed with
  504 after web_server_response_timeout_seconds (default 300, 0 disables),
  freeing its slot instead of pinning it forever.
- Response cap checks the borrowed Text/Binary length before duplicating into
  content_bytes (bounds peak allocation), then re-checks materialized fallbacks.
- WebSocket close reliability: the writer sends a best-effort close frame on any
  exit — explicit Close, empty channel (close server dropped the sender), or a
  Full queue that couldn't carry Close — so `close server` always terminates the
  socket. Outbound send/broadcast now log on a full/closed queue (matching docs).
- REPL uses a no-deadline budget so later commands aren't rejected by a
  session-start timeout (each other ceiling still applies; Ctrl-C interrupts).
- New web_server_response_timeout_seconds config key (registered with
  ConfigChecker); docs updated. Full lib + web + 107 TestPrograms pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A

logbie commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed CodeRabbit's two "outside diff" Major findings on the front-end checkpoints in 4e9ad3c:

  • src/analyzer/mod.rs — moved the budget poll into the recursive analyze_statement (mirroring the parser's parse_statement placement), so nested blocks/loops/try/action/container-method bodies stay interruptible, not just the phase entry. A budget_exhausted flag (reset per analyze run) records the breach once and short-circuits the rest of the traversal, so a large nested body yields a single error instead of one per statement. The entry poll is retained for phase-boundary coverage.
  • src/typechecker/mod.rs — moved the poll into recursive check_statement_types, guarded by the existing budget_error channel (reset per check_types run) as the record-once latch. The top-level loop no longer charges separately; it stops iterating once a breach is recorded, including one surfaced deep inside a prior statement's nested body.

Both polls stay exemption-aware; since max_operations defaults to unlimited, ordinary programs are unaffected — the per-statement charging only applies when an operation cap is set.

Two library-level regression tests isolate the new behavior (parse a 64-statement nested count loop before installing the budget so the parser can't consume the cap, then run under max_operations = 5): one proves the analyzer trips from inside the nested body; the other uses with_analyzer to prove the type checker trips from the recursive poll onto the budget_error channel.

Validated: cargo fmt --check, clippy -D warnings, full cargo test (0 failures), and 107/107 TestPrograms.


Generated by Claude Code

@logbie logbie left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fourth pass on Claude's round-3/CodeRabbit update at 4e9ad3c.

I verified the substantial fixes: main-loop depth is now RAII/nestable and inherited by execute-file; CPU-only main loops yield; inbound WebSocket assembly is capped; Connect fails closed; outbound payloads are measured before cloning; listener Drop cleanup is present; the REPL pre-checks prospective input size; the documented pattern default is corrected; and analyzer/type-checker statement recursion now polls. Exact-head CI, Config Lint, and Claude Code Review are green, and git diff --check is clean.

This is still not ready to merge. Three claimed fixes remain partial (HTTP admission lifetime, fatal type-check propagation, and front-end coverage), with actionable inline comments below.

I also do not accept the two documented deferrals as closing their threads:

  • The thread-local async scope remains a P1. WFL is a public library: src/lib.rs re-exports Interpreter, and two instances can legally be join!ed/spawn_localed. "The current CLI runs one" is not an enforced API invariant. The new REPL guard is another thread-local binding held across .await; task interleaving can still cross-contaminate budgets or restore stale state. Use task-local scoping or explicit propagation.
  • The per-step full-text conversion is not merely an optimization. Pattern input is a runtime value and is not bounded by max_source_size (it can come from unbounded file reads, HTTP/client data, or constructed strings). max_pattern_steps bounds transition count, not the O(text) work/allocation performed before each charge; a 1 MiB input can incur enormous repeated conversions between sampled deadline checks. Convert once before the VM loop and checkpoint before preprocessing.

The prior public-Interpreter stack-safety thread also remains unresolved: the 1 GiB stack is CLI-only/best-effort while the public default depth is still exposed on ordinary embedding stacks.

This is a COMMENT review because the authenticated account is the PR author; treat the P1s as blocking.

Comment thread src/interpreter/mod.rs Outdated
response_sender: Arc::new(tokio::sync::Mutex::new(Some(
response_sender,
))),
_admission: Some(guard),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] Keep the admission slot through response completion, not merely through dequeue. Moving the guard into WflHttpRequest fixes timed-out bodies that are still queued, but WaitForRequestStatement later moves only response_sender into pending_responses; the local request (and this guard) drops when that statement returns. A program can therefore dequeue requests repeatedly without responding, immediately reopening the global admission gate while every route task and pending sender remains unfinished until its 300s timeout. Store the guard with the pending response/completion state and release it on respond, disconnect/timeout pruning, channel failure, or shutdown. An Arc-backed permit shared by the route/queue/pending entry can cover both the pre-dequeue and post-dequeue lifetimes.

Comment thread src/typechecker/mod.rs Outdated
/// caller that otherwise treats `TypeError`s as non-fatal warnings **must**
/// consult this and abort the run when it is `Some`, so a
/// deadline/cancellation/resource breach is never silently ignored.
pub fn take_budget_error(&mut self) -> Option<crate::exec::budget::BudgetExceeded> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] A fatal budget breach cannot be an optional side channel. The include from caller at interpreter/mod.rs:4417 still prints check_types errors as warnings and never calls take_budget_error(), so a deadline breach recorded here is followed by execution of the included program. Also, when TypeChecker::new()'s internal Analyzer::analyze fails at lines 281–293, this field is never populated, so even a caller that checks it sees None. Return a typed result that forces callers to distinguish budget failure from ordinary diagnostics (and propagate the analyzer's typed breach), or update and test every caller before calling this fixed.

Comment thread src/parser/mod.rs
// Implementation of StmtParser trait
impl<'a> StmtParser<'a> for Parser<'a> {
fn parse_statement(&mut self) -> Result<Statement, ParseError> {
// Recursive front-end checkpoint: `parse_statement` is called for *every*

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] This still does not cover the lexer or recursive expression/token work. parse_statement is a statement-boundary poll: one huge list/map/pattern/expression remains a single statement, and the analyzer/type checker likewise recurse through its expressions without another checkpoint. The lexer has no budget call at all, so wfl --lex can process and format the entire capped input after its deadline, and the REPL cannot deliver same-task Ctrl-C while lexing. Add stride checkpoints at token consumption and inside expression/pattern visitors (with direct deadline/cancellation checks), plus a lexer strategy that can yield or run concurrently with cancellation. The new nested-statement tests do not exercise this case.

Comment thread src/interpreter/mod.rs Outdated
writer.abort();
_permit: None,
}) {
log::warn!("WebSocket event queue full; dropping disconnect event for {conn_id}: {err}");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Disconnect is still a lossy control event. Failing closed when Connect cannot be queued fixes the authentication-order issue, but after an admitted connection closes this try_send can still drop the only application cleanup notification. A fast connect/close can leave a queued Connect that later initializes state with no matching Disconnect, and session/resource cleanup in the handler is skipped. Reserve control capacity, use a separate guaranteed lifecycle channel, or otherwise ensure each admitted Connect has a corresponding Disconnect.

claude added 8 commits July 12, 2026 14:30
… channel

A shared-budget breach recorded during type checking was an optional side
channel (`take_budget_error`): the `include from` caller printed the type
errors as non-fatal warnings and then executed the included program, so a
deadline / cancellation / resource breach hit while checking an included
file was silently followed by running it. And when `TypeChecker::new()`'s
internal `Analyzer::analyze` failed, the breach was rendered as ordinary
`TypeError`s while the budget channel stayed empty — so even a caller that
checked the side channel saw `None`.

Make the distinction impossible to miss at the type level: `check_types`
now returns `Result<(), TypeCheckError>` where `TypeCheckError::Budget`
(fatal — stop the run) is separate from `TypeCheckError::Types` (ordinary
diagnostics). The analyzer records its breach on a typed `budget_error`
latch (`take_budget_error`), which `check_types` propagates as the fatal
variant, closing the analysis-phase gap.

Every caller now distinguishes the two:
- `include from`: a budget breach is fatal (return the catchable
  resource/timeout error) instead of a printed warning; ordinary type
  diagnostics stay non-fatal warnings as before.
- `load module`, main CLI, REPL: budget breach aborts the run; type
  diagnostics keep their existing handling.
- LSP (report-only) renders either via `into_diagnostics()`.

Tests: analysis-phase and type-check-phase breaches now assert the fatal
`TypeCheckError::Budget` variant; existing type-checker/LSP call sites
updated to the typed result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
`PatternVM::step` re-ran `text.chars().collect()` on every transition, so a
single match against a large runtime input performed O(text) allocation and
copying per step. Pattern input is a runtime value that is NOT bounded by
`max_source_size` (it can be an unbounded file read, HTTP/client data, or a
constructed string), and `max_pattern_steps` bounds the transition count,
not the O(text) work done before each charge — so a ~1 MiB input could incur
enormous repeated conversions between sampled deadline checks.

Materialize the input into `Vec<char>` exactly once at the top of each
runner (`run_execute` / `run_find` / `run_find_all`) and thread the `&[char]`
slice through `execute_at_position`, `find_at_position`, and `step`. Charge
the budget once *before* that collection so an already-expired deadline or
cancelled run aborts without paying to materialize a large input. Capture
extraction and lookbehind slicing now reuse the same slice, and the position
loops bound on `chars.len()` (character length) instead of `text.len()`
(byte length), which also removes redundant no-op start positions on
multibyte input.

Tests: matching by character index across multibyte input; and a cancelled
run aborting before materializing a 1 MiB input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
Moving the in-flight admission guard into the queued `WflHttpRequest` fixed
timed-out bodies that were still queued, but once the interpreter dequeued a
request, `WaitForRequestStatement` moved only `response_sender` into
`pending_responses` and dropped the local request (and its guard) when the
statement returned. So the global admission gate reopened at dequeue: a
handler could dequeue requests in a loop without responding, immediately
readmitting new work while every route task and pending sender stayed
unfinished until its timeout.

Carry the admission slot with the parked response for its whole post-dequeue
life. `pending_responses` now stores a `PendingResponse { sender, admission }`,
and `respond` moves the slot into the `ResponseCompletion` guard, so it is
released only when the response actually completes — on success, on the
`Drop` 500 fallback, on disconnect/timeout pruning (a dropped pruned entry
releases it), or on shutdown (the map drops). The slot now spans the full
request lifetime, dequeue through response completion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
The connect event is fail-closed, so every live connection has run (or will
run) its `on websocket connect` handler, which may initialize per-connection
application state. The disconnect event — the only place that state is
cleaned up — used a lossy `try_send`, so a momentarily-full event queue could
drop it, leaking application state and leaving a queued Connect with no
matching Disconnect.

Deliver the disconnect with a blocking `send().await` instead: it resolves as
the interpreter drains the bounded event queue, and returns `Err` only when
the server has shut down (its receiver dropped), where no disconnect handler
remains to run anyway. Message events keep their lossy `try_send` (dropping a
message under overload is acceptable backpressure); only the lifecycle pair is
made guaranteed. Under normal load the send completes immediately, so there is
no behavior change except under the full-queue race this fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
…ss-contaminate

`Interpreter` is re-exported from the crate root and is `!Send`, so a library
embedder can legally `join!` or `spawn_local` two interpreter futures on one
thread. The current budget was installed in a thread-local held across every
`.await` (the interpreter run and the REPL command), so when two runs
interleaved on a thread the second overwrote the first's current budget and
restored stale state — the runs cross-contaminated.

Install the run budget in a `tokio::task_local!` instead, via a new
`ExecutionBudget::scope(budget, future)` that wraps the run. Task-local state
is per-future, so each interleaved run keeps seeing its own budget across
awaits. `interpret` is now a thin wrapper that scopes the budget around the run
body (`interpret_inner`); an `execute file` child nests its own scope. The REPL
command is likewise wrapped in `scope`.

`ExecutionBudget::enter` (thread-local) is retained only as a synchronous
fallback for the CLI front-end (lex/parse/analyze/type-check), which runs to
completion without awaiting on a single-future runtime and therefore cannot
interleave. `current()` consults the task-local scope first and this fallback
only when no async scope is active — and since every async run establishes a
scope that shadows it, the fallback can never cross-contaminate a run.

Test: two runs with distinct budgets interleaved via `join!` on a
current-thread runtime each keep observing only their own budget across an
`.await` — the property a thread-local violates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
…lysis

The front-end checkpoints only polled the budget at statement boundaries and
during recursive *statement* traversal, so one huge expression — a
million-element list, a long flat operator chain — was a single statement that
lexed, parsed, analyzed, and type-checked to completion after the deadline. The
lexer had no budget call at all, so `wfl --lex` processed the whole
(source-size-capped) input past its deadline and a same-task Ctrl-C could not
interrupt lexing.

Add strided/recursive checkpoints across the front end:

- Lexer: every 4096 tokens, consult the run budget and stop tokenizing
  cooperatively on a deadline/cancellation/operation breach (it returns `Vec`,
  so it yields the tokens gathered so far; the truncated stream then trips the
  parser's checkpoint).
- Parser: a strided checkpoint at every primary-expression parse (the universal
  operand leaf — list elements, operator-chain terms, call arguments), so a
  single giant expression is interruptible, returning the breach as a
  `ParseError`.
- Analyzer / type checker: poll the budget at the top of `analyze_expression`
  and `infer_expression_type`, latched via `budget_error`, so a huge expression
  tree inside one statement is interruptible during analysis and type checking.

All polls are exemption-aware and no-ops when no run budget is installed, so
normal programs (default `max_operations` unlimited) are unaffected. Test: a
single 6000-element list literal trips the parser checkpoint under a tiny
operation cap, where the old parser ran to completion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
The interpreter's async recursion is stack-heavy (~1 MiB per WFL call), so its
`max_call_depth` only turns runaway recursion into a clean, catchable error on
a stack large enough to reach the limit. The 1 GiB stack was set up privately in
the CLI's `main`, so a library embedder driving `Interpreter::interpret`
directly on an ordinary 8 MiB thread with the default `max_call_depth` (1000)
could crash the host process with a native stack overflow that no depth limit
can catch.

Extract the large-stack thread setup into a public
`wfl::run_with_interpreter_stack(work)` helper (and a `INTERPRETER_STACK_SIZE`
constant), documented as the entry point embedders should wrap their runtime +
`interpret` call in — the same one the CLI now uses (so the logic is shared, not
duplicated). It returns `Err` only if the large stack cannot be reserved, so a
caller can fall back or lower `max_call_depth`. The `Interpreter` type gains a
`# Stack safety` doc section pointing embedders at it.

Test: recursion deep enough to overflow the default test-thread stack completes
when driven through the helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
Document the seven fixes landed for the maintainer's fourth-pass review:
typed fatal type-check result, pattern collect-once, HTTP admission lifetime,
guaranteed WebSocket disconnect, task-local budget scoping, lexer/expression
front-end checkpoints, and the public large-stack embedder helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A

logbie commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed all of the fourth-pass review — the three still-partial fixes, both P1s I had deferred (now accepted as blocking), and the P2 — in 5bf878e. One commit each:

Finding Commit Fix
Fatal type-check breach as an optional side channel (typechecker/mod.rs, interpreter/mod.rs:4417) 8cfda12 check_types now returns Result<(), TypeCheckError> with a distinct fatal Budget variant; the analyzer propagates its typed breach; include from aborts on a budget breach instead of warning-and-executing.
Per-step full-text conversion (r3566272023) 013024a Pattern input is collected into Vec<char> once per runner and threaded as &[char] through step; a checkpoint runs before the O(text) materialization.
Admission slot released at dequeue (interpreter/mod.rs:6046) 7e798b7 The guard now rides with the parked PendingResponse and moves into ResponseCompletion — released only on respond / disconnect-timeout prune / shutdown, spanning the full request lifetime.
Disconnect is a lossy control event (interpreter/mod.rs:554) 0cc0559 Disconnect is delivered with a blocking send().await, so every admitted Connect gets its paired Disconnect (cleanup). Message events keep lossy try_send.
Thread-local budget across .await (r3566272004) 9aab926 Each run and REPL command is wrapped in a tokio::task_local! scope (ExecutionBudget::scope); the retained thread-local enter is only a synchronous fallback for the single-future CLI front-end, always shadowed by an async scope. Test: two runs with distinct budgets interleaved via join! on a current-thread runtime each keep only their own budget across an await.
Front-end coverage: lexer + recursive expr/token (parser/mod.rs:424) 81ccad5 Lexer polls every 4096 tokens (cooperative early-stop); parser polls at every primary-expression parse (strided); analyze_expression/infer_expression_type poll per node (latched). One huge expression is now interruptible.
Public-Interpreter stack safety 28e05b4 Extracted the 1 GiB stack into public wfl::run_with_interpreter_stack (+ INTERPRETER_STACK_SIZE); the CLI shares it, and Interpreter documents that embedders must use it (or a low max_call_depth).

Each fix ships a regression test. Validated: cargo fmt --check, clippy -D warnings (wfl), full lib tests (527), the budget crate (28, incl. the interleaving + huge-expression + large-stack tests), the touched type-checker/LSP/include/execute-file integration crates, 107/107 TestPrograms, and the web-server suite. (A full --workspace test run exhausts this sandbox's disk building every web-test binary, so those crates were validated via the run scripts instead.)


Generated by Claude Code

@logbie logbie left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Final pass on PR #609 at 5bf878e (now 5,349 additions / 5,867 changed lines).

The fifth-round work closes the prior task-local isolation, pattern preprocessing, typed type-check, post-dequeue HTTP lifetime, Disconnect delivery, expression traversal, and embedder-helper implementation issues in their intended paths. Exact-head CI, WFL Config Lint, and Claude Code Review all pass; git diff --check is clean.

It is close, but not fully clear yet. I found two P1 merge blockers in the remediation itself:

  1. lexer budget failure silently returns a successful program prefix;
  2. timed-out dequeued HTTP requests can permanently hold the entire admission cap because cleanup requires a future request that cannot be admitted.

Three smaller correctness/API residuals are also inline: analyzer entry typing, Unicode lookbehind/index semantics, and opt-in rather than default-safe embedding stacks.

I opened issue #611 as the canonical checklist with reproduction sequences and acceptance criteria. The issue is not a reason to merge past the two P1s; those should be fixed on this PR or explicitly accepted before merge. The P2s can be handled here or deliberately scheduled through #611.

This is a COMMENT review because the authenticated account is the PR author.

Comment thread src/lexer/mod.rs Outdated
while let Some(token_result) = lexer.next() {
// Every `LEX_CHECKPOINT_STRIDE` tokens, consult the run budget and stop
// tokenizing cooperatively on a deadline / cancellation / operation
// breach. The lexer returns `Vec` (no `Result`), so this returns the

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] Do not turn a budget failure into a successful partial token stream. This break discards the BudgetExceeded and returns whatever prefix was gathered. wfl --lex then writes that partial dump and exits 0; more seriously, if the prefix ends at a valid statement boundary, parsing can succeed and a sampled deadline/cancellation failure need not immediately repeat on the next charge_operation, allowing a source prefix to be analyzed or executed. Return a typed lexer result (or explicit fatal outcome) and propagate it through every production caller. At this stride, check cancellation/deadline directly as well—nesting the 4096-token stride inside charge_operation's 1024-operation sampling can postpone those checks by millions of tokens.

Comment thread src/interpreter/mod.rs
// released on respond/prune/shutdown, not at dequeue.
pending_responses.insert(
request.id.clone(),
PendingResponse {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] Closed pending entries need timeout-driven cleanup; insertion-time pruning can deadlock the admission gate. Fill the global cap with requests that WFL dequeues but never answers, then have WFL wait for one more. When all route tasks time out, their senders close but these guards remain parked. No new request can be admitted to wake recv(), so no later insertion reaches the retain above and every slot stays consumed until shutdown. Make timeout/disconnect transition a shared request lease independently of future admission (while still keeping queued bodies accounted), and test that the cap reopens after all dequeued requests time out without another request first.

Comment thread src/analyzer/mod.rs Outdated
if let Some(budget) = crate::exec::budget::ExecutionBudget::current()
&& let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt())
{
return Err(vec![SemanticError::new(exceeded.message(), 0, 0)]);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Record entry-point breaches on the typed channel before returning. This branch renders the breach as SemanticError but leaves self.budget_error == None. If TypeChecker::new() enters analysis with an already-exhausted/cancelled budget, its new take_budget_error() call therefore sees nothing and returns TypeCheckError::Types, violating the fatal typed contract. The added test starts below the cap and trips later in analyze_statement, so it misses this branch. Store Some(exceeded) here and add an already-exhausted/cancelled entry test.

Comment thread src/pattern/vm.rs
text_chars[start_pos..state.pos].iter().collect();
// Create a slice of text to match against, from the
// already-materialized character slice.
let text_slice: String = chars[start_pos..state.pos].iter().collect();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Keep Unicode lookbehind entirely in character indices. The refactor correctly makes MatchResult::end a character index, but the full-slice test below still compares it with text_slice.len() (UTF-8 bytes), so positive and negative lookbehind fail or invert when this slice contains multibyte characters. Compare with the slice's character length or pass &[char] through the nested runner. Add both Unicode lookbehind cases. Also fix the public CompiledPattern::find example, which slices &text[m.start..m.end] using these character offsets as byte indices and can panic.

Comment thread src/lib.rs
/// work on the current stack (accepting that deep recursion may hit the OS limit
/// before `max_call_depth`), or set a conservatively low `max_call_depth`. A
/// panic inside `work` is propagated to the caller.
pub fn run_with_interpreter_stack<F, T>(work: F) -> std::io::Result<T>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] This helper is useful, but stack safety is still opt-in rather than a safe default. The crate still re-exports directly callable Interpreter::new/interpret with max_call_depth = 1000, while the documentation says an ordinary embedding stack can overflow near depth 40. An embedder that misses this helper can therefore crash on untrusted WFL long before the advertised guard. Track and enforce a safe public contract: a conservative direct-construction default, a primary runner that owns interpreter creation/execution, or a stack-independent design. Add an ordinary-stack test that the default public path returns a resource error instead of aborting.

…, and three residuals

The fifth review round's own remediation left two P1 merge blockers plus three
correctness/API residuals (issue #611). Fix all five, each with a regression test.

P1 — lexer must not turn a budget breach into a successful partial stream:
The strided lexer checkpoint `break`'d on a breach and returned the prefix, so
`wfl --lex` dumped it and exited 0, and a prefix ending at a statement boundary
could parse/execute. The loop is now a private `lex_positions_core` taking a
checkpoint closure: `lex_wfl_with_positions` supplies a no-op (never truncates —
LSP/tooling/tests unaffected) and the new `lex_wfl_with_positions_checked`
returns `Result<_, BudgetExceeded>`. Every production execution caller (CLI run
and `--lex`, nested execute-file/include/load-module, REPL) propagates it. At
each stride the deadline and cancellation are checked directly (not via
`charge_operation`'s sampling, which nested in the 4096-token stride could
postpone them by millions of tokens); the operation ceiling is charged
separately.

P1 — a timed-out pending HTTP request can no longer wedge admission:
The guard was parked in the interpreter's pending map and pruned only when a
later dequeued request was inserted; once the cap filled with unanswered
requests, no new request could be admitted to trigger the prune, pinning every
slot forever. The guard now rides with the warp transport future (which outlives
the dequeue while awaiting the response), so the slot is held through handling
and released when that future ends — respond, response timeout, or client
disconnect — independently of any future admission. The bounded request mpsc
still caps queued bodies.

P2 — analyzer entry-time breach preserves the typed channel: the phase-boundary
checkpoint now stores `budget_error` before returning, so `TypeChecker::new()`
sees a cancelled/exhausted entry breach as fatal `TypeCheckError::Budget`, not
misclassified `Types`.

P2 — Unicode lookbehind stays in character indices: the full-slice test compared
`MatchResult::end` (chars) against `text_slice.len()` (bytes), inverting
positive/negative lookbehind on multibyte input; it now compares the slice's
character length. The public `CompiledPattern::find` doc example that sliced
`&text[m.start..m.end]` with char offsets is fixed to extract by chars.

P2 — the default public interpreter path is stack-safe: `Interpreter::new()`
now caps recursion at the conservative `DEFAULT_EMBED_CALL_DEPTH` (12) instead
of the config default (1000), which only stays catchable on the CLI's 1 GiB
stack; `with_config`/`with_config_and_budget` still honor the configured depth
so the CLI reaches 1000. A deep program on an ordinary stack now returns a
catchable call-depth error instead of aborting.

Tests: typed lexer outcomes (cancel/deadline/operations), no-truncation, and
`--lex` exit; a live-server admission-reopens-after-timeout regression; a
cancelled-budget analyzer entry test; positive/negative Unicode lookbehind over
a 2-byte char; and an ordinary-8-MiB-stack embedding test. Dev Diary updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A

logbie commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed all of issue #611 — both P1 blockers in the fifth-round remediation and the three residuals — in 8bd70bb, each with a regression test.

# Finding Fix
P1-1 Lexer breach silently truncates (--lex exits 0; a valid prefix can parse/execute) The lexer loop is now a private lex_positions_core taking a checkpoint closure. lex_wfl_with_positions supplies a no-op (it can no longer truncate under any budget state — LSP/tooling/tests are safe); the new lex_wfl_with_positions_checked returns Result<_, BudgetExceeded>. Every production execution caller propagates it: CLI run + --lex, nested execute file/include/load module, and the REPL. At each stride the deadline and cancellation are checked directly (not through charge_operation's 1024-op sampling, which nested in the 4096-token stride could postpone them by millions of tokens); the operation ceiling is charged separately.
P1-2 Timed-out pending requests permanently consume every admission slot The admission guard no longer parks in the interpreter's map (pruned only by a future insert — impossible once full). It now rides with the warp transport future, which outlives the dequeue while awaiting the response, so the slot is held through handling and released when that future ends — respond, response timeout, or client disconnect — independently of any future admission. The bounded request mpsc still caps queued bodies.
P2-3 Analyzer entry breach not on the typed channel The phase-boundary checkpoint now stores budget_error before returning, so TypeChecker::new() entering with an already-cancelled/exhausted budget sees the fatal TypeCheckError::Budget, not misclassified Types.
P2-4 Unicode lookbehind compares char index with byte length The full-slice test now compares MatchResult::end (a char index) against the slice's character length (start_offset), not text_slice.len() (bytes). The public CompiledPattern::find doc example that sliced &text[m.start..m.end] with char offsets is fixed to extract by chars.
P2-5 Public interpreter stack safety is opt-in Interpreter::new() now caps recursion at the conservative DEFAULT_EMBED_CALL_DEPTH (12) instead of the config default 1000; with_config/with_config_and_budget still honor the configured depth, so the CLI reaches 1000 on its 1 GiB stack. A deep program on an ordinary stack returns a catchable call-depth error instead of aborting.

Acceptance criteria — all met:

  • ✅ Lexer never returns a successful partial stream after a budget failure (typed cancel/deadline/operations tests; --lex now exits non-zero and writes no dump; the plain lexer proven never to truncate).
  • ✅ HTTP admission reopens after a pending-response timeout with no new request first (live-server regression fills the cap with unanswered requests, waits out the timeout, and proves the next request is admitted — 504, not 503).
  • ✅ Analyzer entry breaches preserve BudgetExceeded typing (cancelled-budget entry test).
  • ✅ Unicode positive/negative lookbehind and the public offset example are correct (both directions pinned over a 2-byte é; find doctest passes).
  • ✅ The default public path returns a resource error before native overflow (ordinary-8-MiB-stack embedding test — note: depth 24 empirically overflows an 8 MiB debug stack, so the default is set to 12).

Validated: cargo fmt --check, clippy -D warnings (lib+bins), lib tests (529), the budget crate (36, incl. the six new lexer/analyzer/stack tests), the new web-admission regression, pattern doctests (9), and the execute-file/queue-bound integration crates. A Dev Diary "sixth review round" section documents all five.

One judgment call worth flagging: on P2-5 I chose the conservative-default option (option 1 from the issue) — new() is shallow-but-safe, config constructors opt into more. If you'd prefer a stack-independent design or a dedicated runner API instead, happy to adjust.


Generated by Claude Code

@logbie logbie left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sixth-round follow-up on 8bd70bb: the five #611 remediations are present and the principal fixes look correct. The HTTP guard now has transport-future lifetime, analyzer typing and Unicode indexing are corrected, the default embedding depth is conservative, and execution callers use the typed lexer path.

One residual remains in the typed lexer boundary behavior (inline). The strided checkpoint is only called after token 4096, so a short input can succeed even when its budget is already cancelled or expired. This is most directly visible in --lex, which has no later parser/analyzer checkpoint and can write a dump and exit 0 under an already-expired budget.

CI on this exact head is otherwise healthy: all completed jobs pass; only Windows Run All Integration Tests remains in progress at the time of review.

This is a COMMENT review because the authenticated account is the PR author.

Comment thread src/lexer/mod.rs
let mut last_span_end = 0;
// Strided run-budget checkpoint counter. The lexer is otherwise a tight loop
// with no budget call, so a large (source-size-capped) input would tokenize
// fully even after the run's deadline, and a same-task Ctrl-C could not

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] Check the budget at the lexer boundary, not only after a full stride. checkpoint() is first invoked when lexed_tokens == 4096, so lex_wfl_with_positions_checked("display 1\n") returns Ok even if the installed budget was already cancelled or has a zero/expired deadline. In --lex there is no later phase to catch that breach, so a short source can still produce a successful dump under an already-expired run budget. Invoke the checkpoint once before entering the loop (and preferably once after the loop to catch a breach during the final partial stride), while retaining strided checks for long inputs. Add short-input cancelled/deadline regressions.

… stride

The strided lexer checkpoint fired only at token 4096, 8192, … so a source
shorter than one stride never consulted the budget: an already cancelled or
already expired budget lexed to a successful `Ok`. This was most visible in
`--lex`, which has no later parser/analyzer checkpoint and so could dump a short
source and exit 0 under an already-breached run budget.

`lex_positions_core` now invokes the checkpoint once before the first token
(catching an already-breached budget for any input length) and once after the
last (catching a breach during the final partial stride), keeping the strided
checks for long inputs. The non-budgeted `lex_wfl_with_positions` still supplies
a no-op checkpoint, so it is unaffected.

Tests: `lex_wfl_with_positions_checked("display 1\n")` under an already-cancelled
budget and under an already-expired deadline both abort with the typed breach at
the entry checkpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A

logbie commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed the lexer-boundary residual in 285d2e0.

The strided checkpoint fired only at token 4096+, so a source shorter than one stride never consulted the budget — an already-cancelled/expired budget lexed to a successful Ok, and --lex (no later phase) could dump a short source and exit 0. Per your suggestion, lex_positions_core now consults the checkpoint once before the first token (catches an already-breached budget for any input length) and once after the last (catches a breach during the final partial stride), keeping the strided checks for long inputs. The non-budgeted lex_wfl_with_positions still uses a no-op checkpoint, so it's unaffected.

Regressions added: lex_wfl_with_positions_checked("display 1\n") under an already-cancelled budget → Cancelled, and under an already-expired deadline → Deadline, both aborting at the entry checkpoint. (The --lex config path can't reproduce an expired-at-entry deadline deterministically — timeout_seconds is clamped to max(1) and a short lex completes in microseconds — so the unit tests pin the mechanism directly, matching your inline example.)

Validated: cargo fmt --check, clippy -D warnings (lib+bins), lib tests (529), the budget crate (38, incl. the two new short-input regressions and the existing long-input --lex test).


Generated by Claude Code

@logbie logbie left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Final verification on 285d2e0: no remaining findings.

The short-input lexer residual is fixed correctly: the checked core now polls at entry, at each stride, and after the final partial stride; cancelled/expired short inputs have typed regressions; and the unbudgeted lexer remains unaffected through its no-op checkpoint. I also rechecked the prior #611 remediation paths and found no new regression introduced by this delta.

Exact-head CI is fully green: CI, WFL Config Lint, and Claude Code Review all completed successfully. From this review, PR #609 is ready to merge.

This is a COMMENT review only because GitHub does not allow the authenticated PR author to approve their own pull request.

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.

3 participants