diff --git a/CLAUDE.md b/CLAUDE.md index f8437ff6..588d67c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -215,7 +215,7 @@ Source Code → Lexer → Parser → Analyzer → Type Checker → Interpreter - **Rules**: Refer to `.cursor/rules/wfl-rules.mdc`. ## Technical Requirements -- **Rust Edition**: 2024 (Min: 1.75+, Dev: 1.91.1+) +- **Rust Edition**: 2024 (MSRV: 1.88+ — the codebase uses `let`-chains; Dev: 1.91.1+) - **Versioning**: YY.MM.BUILD (e.g., 26.1.22). Major version always < 256 (Windows MSI compatibility). - **Key Dependencies**: - `logos`: Lexer diff --git a/Cargo.toml b/Cargo.toml index 1ef7592a..14a1374c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,10 @@ name = "wfl" version = "26.7.35" edition = "2024" +# Minimum supported Rust version. The codebase uses `if let … && …` let-chains +# (stabilized in Rust 1.88) throughout, so 1.88 is the true floor — recorded +# here so `cargo` fails fast on older toolchains instead of deep in a build. +rust-version = "1.88" description = "WFL (WebFirst Language) is a programming language designed to be readable and intuitive using natural language constructs." license = "Apache-2.0" authors = ["Logbie LLC "] diff --git a/Dev diary/2026-07-12-shared-execution-budget.md b/Dev diary/2026-07-12-shared-execution-budget.md new file mode 100644 index 00000000..02ecee4e --- /dev/null +++ b/Dev diary/2026-07-12-shared-execution-budget.md @@ -0,0 +1,465 @@ +# Dev Diary — 2026-07-12: Shared ExecutionBudget + +## Context + +WFL enforced a dozen unrelated resource ceilings from a dozen unrelated places, +and several audit-flagged resources had **no** ceiling at all: + +| Resource | Before | +|---|---| +| Wall-clock timeout | `Interpreter.max_duration` + `op_count` throttle (`check_time`) | +| Interpreter operations | none (the op counter only throttled clock reads) | +| Recursion depth | none in release (a `debug_assert!(< 10_000)` only) | +| Import / include depth | circular-detection only; otherwise unbounded | +| `execute file` depth | a lone `const MAX_EXECUTE_FILE_DEPTH = 4` | +| Pattern transitions | a lone `const MAX_STEPS = 100_000` in the VM | +| Pattern active states | none — the NFA state set could fan out unbounded | +| Source-file size | none | +| HTTP request body | `web_server_max_body_size` | +| HTTP response body | none | +| Pending HTTP requests | `web_server_request_queue_bound` | +| WebSocket queues | none — `unbounded_channel` (flagged as a Phase 0 follow-up) | +| WebSocket connections | none — the registry grew without bound | + +This entry 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 above. + +## What changed + +### New module `src/exec/budget.rs` + +- `BudgetLimits` — the immutable per-run ceilings. `BudgetLimits::from_config` + maps the existing `.wflcfg` keys (`timeout_seconds`, + `web_server_max_body_size`, `web_server_request_queue_bound`) plus the new + budget keys onto its fields, so nothing about the old knobs changed. +- `ExecutionBudget` — the shared object. **`Send + Sync`**: every mutable field + is an atomic (`AtomicU64`/`AtomicUsize`/`AtomicBool`), so an + `Arc` clones into the multi-threaded web transport (warp + accept tasks, per-connection WebSocket tasks) without any `Rc`/`RefCell` + crossing a thread boundary. This does **not** violate the "no `Rc`→`Arc` + rewrite of the interpreter core" rule — the budget is a separate atomic-only + object, shared exactly like `Arc` already is. +- `BudgetExceeded` — a typed breach that each subsystem maps onto its own error + (`RuntimeError` in the interpreter, `PatternError` in the VM). The deadline + variant keeps the historic `"Execution exceeded timeout (Ns)"` wording and + `ErrorKind::Timeout` so existing timeout handling/tests still match; a new + `ErrorKind::ResourceLimit` covers the rest. +- RAII guards (`RequestGuard`, `WsConnectionGuard`) release their atomic slot on + drop. + +### Interpreter (`src/interpreter/mod.rs`) + +- Removed the `op_count`/`started`/`max_duration` fields; added + `budget: Arc`. `check_time()` now calls + `budget.charge_operation(enforce_limits)` where `enforce_limits = !in_main_loop` + — preserving the rule that a `main loop` is exempt from the timeout, while + cooperative cancellation still applies. Clock reads stay throttled to one per + 1024 operations inside the budget. +- Recursion ceiling: `call_function` checks `budget.check_call_depth(stack_len)` + before pushing a frame. +- Import ceiling: both `load module` and `include from` check + `budget.check_import_depth(loading_stack_len)` after the circular check. +- `execute file` depth: `MAX_EXECUTE_FILE_DEPTH` const deleted; the guard is now + `budget.check_execute_file_depth(execute_depth)` (default still 4). +- Response ceiling: `respond` checks `budget.check_response_bytes(len)` before + handing the body to the transport. +- Request body + pending-request bounds now read from the budget + (`max_request_body_bytes`, `max_pending_requests`); values are identical to the + old config reads. +- WebSocket bounding (the deferred Phase 0 follow-up): the per-connection + outbound channel and the per-server event channel became **bounded** + `mpsc::channel(ws_queue_bound)` with `try_send` + shed-on-`Full` logging; a new + connection acquires a `WsConnectionGuard` and is refused (close frame + log) + past `max_ws_connections`. + +### Pattern VM (`src/pattern/vm.rs`, `src/pattern/mod.rs`) + +- `PatternVM` holds an `Arc`. `MAX_STEPS` deleted; each match + attempt checks `step_count` against `budget.pattern_step_limit()` and — new — + the active-state set against `budget.pattern_state_limit()` (guards exponential + fan-out). Added `PatternError::StateLimitExceeded` and `Cancelled`. +- `PatternVM::new()` uses a standalone budget with the historic ReDoS defaults + (steps 100_000), so stdlib pattern builtins keep their guard. New + `CompiledPattern::{matches,find,find_all}_with_budget` let the interpreter's + `matches`/`find` operators share the run budget (deadline/cancellation-aware); + nested lookaround VMs share the parent's budget. + +### Source size + a real recursion guard (`src/main.rs`) + +- After loading config, the CLI refuses an over-`max_source_size` file before + lexing. +- **Large-stack thread.** WFL's async tree-walking interpreter costs on the + order of a *megabyte* of debug stack per WFL call (an 8 MiB stack overflows + near depth ~40). A `max_call_depth` guard is meaningless if the OS stack + overflows first, so `main` now runs the whole runtime on a dedicated thread + with a **1 GiB** stack (reserved virtually, committed lazily). With it, the + default ceiling of 1000 fires as a clean error well before the native limit in + both debug (~1460-frame floor) and release. Empirically: depth 999 completes, + depth ≥ 1000 returns *"Maximum call depth (1000) exceeded"* instead of + `SIGABRT`. + +### Config (`src/config.rs`) + +- Nine new keys with positive-integer validation (`max_operations` accepts `0` = + unlimited): `max_operations`, `max_call_depth`, `max_import_depth`, + `max_execute_file_depth`, `max_pattern_steps`, `max_pattern_states`, + `max_source_size`, `web_server_max_response_size`, `web_socket_queue_bound`, + `web_socket_max_connections`. A shared `set_positive_usize` helper keeps the + parse arms terse. + +## Backward compatibility + +- The three pre-existing knobs (timeout, request body, request queue) keep their + defaults and behavior exactly; the budget just sources their values. +- The two potentially-breaking dimensions default to no new failures: + `max_operations` is off by default, and every new ceiling (recursion, import, + states, source/response bytes, WS) defaults generously enough that no existing + program or `TestPrograms/` case trips it — the recursion default (1000) is far + above any depth a program completed at under the old 8 MiB stack, and, thanks + to the large stack, programs can now recurse *deeper* than before while + runaway recursion becomes a clean error instead of a crash. + +## Tests + +- `src/exec/budget.rs`: 11 unit tests — operation ceiling, main-loop exemption, + cancellation-always-honored, deadline, `>=` depth vs `>` pattern semantics, + byte ceilings, RAII request/WS-connection guards, `from_config` mapping, + `Send + Sync` assertion. +- `tests/execution_budget_test.rs`: config parsing of all new keys (defaults, + overrides, zero/garbage rejection, `max_operations = 0`), and end-to-end — + deep recursion is a clean *"Maximum call depth (1000)"* error and **not** a + stack overflow; a configured low ceiling is honored; an oversized source is + refused; a shallow program still runs. +- Existing suites unchanged: full lib tests (incl. `test_timeout_forever_loop`), + `web_queue_bound_test`, `websocket_test`, `execute_file_test`, and the + `TestPrograms/` integration run all pass. + +## Review follow-ups (automated PR review) + +Addressed in the same PR after the first round of automated review: + +- **Budget spans `execute file`.** The nested interpreter now clones the + parent's `Arc` instead of building a fresh one, so the + deadline, operation ceiling, and cancellation cover the whole run — work can't + be split across executed files to evade them. Regression test: + `execute_file_shares_the_parent_operation_budget`. +- **Nested source sizes are checked.** `load module`, `include from`, and + `execute file` enforce `max_source_size` via file metadata *before* reading, + matching the top-level guarantee (which now also checks metadata pre-read). + Regression test: `nested_execute_file_source_is_size_checked`. +- **Catchable errors keep the call stack.** `budget_error` only force-clears the + call stack for the terminal deadline; a `ResourceLimit` (e.g. the recursion + ceiling) is catchable by `try`/`when`, so the stack is left for + `call_function` to unwind frame-by-frame — otherwise the depth counter + (`call_stack.len()`) would under-count after a caught recursion error. +- **Pattern state cap fails fast.** The active-state ceiling is now checked after + each expansion inside the step loop, not only once the whole next generation is + built, so a runaway step can't allocate far past the cap. +- Doc/comment accuracy fixes: `PatternVM::new` no longer references the removed + `MAX_STEPS`; the VM error-mapping comment no longer implies it samples the + deadline; the CLI comment says "same config", not "same budget instance"; and + the duplicate "Execution budget (resource limits)" doc heading was renamed so + the anchor stays unique. + +## Deep review round (maintainer P1/P2 + bot reviewers) + +A subsequent maintainer review raised five blocking findings and several P2s; +all were addressed on this PR: + +- **P1-1 — recursion guard robust under catch.** Enforcement depth moved to a + dedicated RAII `call_depth` counter, decoupled from the diagnostic `call_stack`. + `budget_error` no longer mutates *any* interpreter state (every breach is + catchable), and `interpret()` resets per-run loop/depth state, so + catch-and-recurse stays bounded and an enclosing `count` loop survives a caught + recursion error. Test: `catching_a_recursion_limit_leaves_a_consistent_interpreter`. +- **P1-2 — pattern transitions counted for real.** Charged per *instruction* in + `step()` (bounds epsilon-jump cycles) on one shared meter that nested + lookaround/lookbehind VMs charge into (no reset); breaches now *propagate* as + catchable `ResourceLimit` errors through the interpreter operators AND the + stdlib builtins (`pattern_matches/find/find_all/split`), which reach the run + budget via a thread-local. `max_pattern_steps` default raised to 5_000_000 for + per-instruction granularity. +- **P1-3 — HTTP OOM/leak paths closed.** Request body enforced *while streaming* + (chunked-safe → 413); one **global** in-flight `RequestGuard` (shared across + listeners) held from body-read until response / disconnect / 504 timeout + (`web_server_response_timeout_seconds`, default 300); response cap checks the + borrowed length before duplicating. +- **P1-4 — one budget per run.** `main.rs` builds a single budget up front and + threads it through the source check and the interpreter + (`with_config_and_budget` + `budget()` handle); `execute file` shares it. +- **P1-5 — bounded source loader everywhere.** A bounded reader (≤ `max+1` bytes) + covers the CLI, `load module`, `include`, `execute file`, and the REPL, so + oversized sources are refused without allocation even when metadata lies. +- **P2** — `ConfigChecker` now knows every budget key (so `--configFix` can't + strip them); WebSocket `close server` sends a guaranteed close frame even under + backpressure and logs full/closed outbound queues; the large stack is deferred + for `--help`/`--version` and falls back on reservation failure; the REPL uses a + no-deadline budget; MSRV recorded as 1.88 (`let`-chains). + +## Second deep review round (maintainer P1×8 + P2×7) + +A second maintainer review found the first round's mechanisms incomplete. All +findings were addressed on this PR: + +- **Pattern meter is now per-match, not run-global.** The transition counter no + longer lives on the shared `ExecutionBudget` (two concurrent matches sharing one + `Arc` could reset each other's meter and grant unbounded quota). A new + `PatternMeter` owns the per-match transition and active-state counters and is + cloned only into nested lookaround/lookbehind VMs; it also **samples the + wall-clock deadline** on the transition stride (with the `main loop` exemption, + tracked via a `deadline_exempt` flag), surfacing a runaway synchronous match as + a catchable timeout. Active-state slots are reserved/released across the + current, next, and nested frontiers via one RAII `StateReservation`. The public + `PatternVM::find`/`find_all` API is restored (`Option`/`Vec`) with separate + `try_find`/`try_find_all` fallible entry points. +- **HTTP request lifetime is fully bounded.** One deadline set at admission now + covers the body read *and* the handler response (a slow trickle upload sheds + with 408; a stuck handler with 504). A timed-out request's closed oneshot is + observed so the interpreter skips an abandoned request and prunes closed + `pending_responses` entries instead of running zombie work. `respond` takes the + sender into an RAII completion guard (500 on any early error) and rejects + composite/opaque bodies rather than materializing an unbounded `{:?}` string. + `read_body_capped` keeps its `max + 1` bound exact. +- **WebSocket memory is bounded by bytes.** A per-message size cap + (`web_socket_max_message_size`) plus a global queued-byte permit + (`web_socket_max_queued_bytes`, RAII-released on send/consume/shed) bound both + the event queue and the outbound per-connection queues. `close server` signals + a per-server cancellation watch so each reader stops (releasing its connection + slot) even if the peer ignores the close handshake, with a bounded + close-handshake timeout. +- **Recursion accounting spans `execute file`.** A child interpreter seeds its + base recursion depth from the parent's live depth (and `interpret()` resets to + that base), so nested execute files cannot each claim a fresh `max_call_depth` + allowance and multiply the native stack toward overflow. +- **The front end consults the budget.** The run budget is installed as the + current-thread budget for the whole run, and the parser (per top-level + statement), the type checker (per top-level statement), and the analyzer (phase + boundary) poll it — so `--analyze`, the dump modes, and a slow parse honor the + deadline/cancellation instead of only measuring elapsed time. +- **The REPL is bounded and cancellable.** It loads the applicable `.wflcfg` + (not defaults), enforces `max_source_size` immediately after each appended line + (before clone/lex/parse), gives each command a fresh per-command budget (a real + deadline, environment preserved), and wires Ctrl-C during execution to + `budget.cancel()`. +- **Config-tool validation matches the loader.** Budget/web integer keys are + validated as non-negative `u64` with the loader's exact per-key minimums, the + `Execution Budget` category is included in the config wizard, and the two new + WebSocket byte keys are registered. + +### Automated-review follow-ups (round 2) + +Addressed after the round-2 bot review: + +- **`deadline_exempt` survives nested `execute file`.** The child shares the + parent's budget and its `interpret()` clears the shared exemption; the parent + now saves and restores `deadline_exempt` around the nested run, so a parent + still inside its own `main loop` doesn't start enforcing the wall-clock + deadline on pattern matches (and time out spuriously) after the child returns. +- **Cooperative yield in the loop hot path.** `_execute_statement` yields to the + async runtime on a throttled stride (outside a `main loop`) so a tight + CPU-bound `count`/`while`/`repeat` loop returns control to the executor, + letting the REPL's Ctrl-C → `budget.cancel()` actually fire. +- **Config checker minimums** now include `timeout_seconds` and + `web_server_max_body_size` (both `>= 1` in the loader), and the stale + `budget_error` doc that claimed the deadline force-clears the call stack was + corrected to match the no-mutation behavior. + +## Third deep review round (maintainer P1×10 + P2×6) + +A third maintainer review found the round-2 exemption/yield fixes only partial +and raised further concurrency/lifecycle/peak-allocation gaps. Addressed on this +PR (three commits): + +- **Main-loop exemption is now a shared depth counter + RAII guard.** Replaced + the interpreter `in_main_loop` bool and budget `deadline_exempt` bool with one + `main_loop_depth: AtomicUsize` and a `MainLoopGuard`. It restores on *every* + exit — normal, early return, a caught error unwinding through `?`, and nested + loops — so the exemption never leaks or clears an outer loop; an `execute file` + child sharing the budget inherits the parent's active exemption (the front-end + checkpoints are exemption-aware, so the nested parse no longer times out); and + the pattern meter reads the exemption **live** (no snapshot), so reusing one VM + across a main-loop boundary is correct. The cooperative yield now uses a + dedicated per-statement counter that advances even inside a `main loop`. +- **WebSocket + HTTP lifecycle hardened.** Transport-level WS message/frame caps + (`Ws::max_message_size`/`max_frame_size`) before upgrade; connect events + fail-closed (unregister + close) so a live socket can't skip its connect + handler; per-message reservation on the *borrowed* length before cloning + (send + broadcast); `Drop` cleanup that aborts the accept task for both server + kinds; and the global HTTP admission guard now travels *inside* the queued + request so a queued body counts until dequeue/drop, not merely until the route + future ends. +- **Budget scoped across the whole REPL/front-end pipeline**, a type-check budget + breach is fatal (not a warning), and more front-end checkpoints + (`Analyzer::analyze`, recursive `parse_statement`) keep nested parses/analysis + interruptible. The REPL source cap is checked on the prospective length before + `push_str`. + +### Deferred (with rationale) + +- **Task-local budget (interleaving two-budget case).** WFL runs exactly one + interpreter future per thread: the CLI runs one program; the web server + processes requests serially on a single interpreter via the event channel; the + REPL runs one command at a time. Two interpreter futures never interleave on + one thread (no `join!`/`spawn_local` of interpreter runs), and the thread-local + budget guard correctly save/restores for *nested* runs (`execute file`). The + interleaving hazard therefore does not occur in the current architecture; a + `tokio::task_local!` conversion is defense-in-depth that would require + restructuring every run entry point. Tracked as a follow-up. +- **Collect pattern text once per top-level match.** Total pattern work is + already bounded by `max_pattern_steps` and the source-size cap; the per-`step` + `chars` re-collection is a real inefficiency but the fix threads `&[char]` + through the VM's position/step signatures — a delicate change to a + heavily-tested subsystem. Tracked as a follow-up. + +## Fourth review round (automated: recursive front-end coverage) + +The round-3 front-end checkpoints closed the *entry* gap but a bot reviewer +correctly noted they were only partial: `Analyzer::analyze` polled the budget +once at the phase boundary, and `TypeChecker::check_types` polled once per +*top-level* statement — but both then recurse (`analyze_statement`, +`check_statement_types`) into every nested block, loop, `try`, action, and +container-method body without re-checking. A single deeply nested top-level +statement could therefore run the analyzer or type checker to completion without +honoring the run's deadline/cancellation/operation budget, unlike the parser +whose checkpoint already lives inside the recursively-called `parse_statement`. + +Fixed by moving the poll into the recursive methods, mirroring the parser: + +- **Analyzer** — `analyze_statement` now polls the budget at its top. A new + `budget_exhausted` flag (reset per `analyze` run) records the breach once and + short-circuits every remaining node, so a large nested body yields a single + `SemanticError` rather than one per statement. The phase-boundary poll in + `analyze` is retained. +- **Type checker** — `check_statement_types` now polls at its top, guarded by the + existing `budget_error` channel (reset per `check_types` run) as the + record-once / short-circuit latch. The top-level loop no longer charges + separately; it just stops iterating once `budget_error` is set — including a + breach surfaced deep inside a prior statement's nested body. + +Both polls stay exemption-aware (`charge_operation(!is_deadline_exempt())`), and +because `max_operations` defaults to `None`, ordinary programs are unaffected — +the extra per-statement charging only bites when a user opts into an operation +cap, which is exactly the pathological-input protection the review asked for. + +Two library-level regression tests isolate the new behavior +(`tests/execution_budget_test.rs`): a program whose single top-level `count` loop +holds 64 nested statements is parsed *before* any budget is installed (so the +parser's own checkpoint can't consume the cap), then run under `max_operations = 5`. +`analyzer_polls_the_budget_inside_nested_bodies` asserts `analyze` returns the +operation-budget breach (its entry poll alone charges only index 0, so the breach +can only come from the nested traversal); `type_checker_polls_the_budget_inside_nested_bodies` +uses `with_analyzer` to skip the analyzer pass, proving the breach comes from the +recursive `check_statement_types` poll and lands on the `budget_error` channel. + +## Fifth review round (maintainer P1×5 + P2 — the two deferrals recalled) + +A fourth maintainer pass verified the round-3/round-4 fixes but did **not** +accept the two documented deferrals and flagged three fixes as still partial. +All seven items landed on this PR (one commit each): + +- **Type-check budget breach is now a fatal typed result, not a side channel.** + `check_types` returns `Result<(), TypeCheckError>` where `Budget` (fatal) is a + distinct variant from `Types` (ordinary diagnostics), so every caller must + distinguish them at the type level. The analyzer records its breach on a typed + `budget_error` latch that `check_types` propagates, closing the gap where an + analysis-phase breach was rendered as `TypeError`s and the budget channel + stayed empty. The `include from` caller now aborts on a budget breach instead + of printing it as a warning and executing the included program. +- **Pattern input is collected once, with a checkpoint before preprocessing.** + `PatternVM::step` re-ran `text.chars().collect()` on every transition — O(text) + per step for unbounded runtime input (`max_source_size` bounds *source*, not + pattern subjects). The `Vec` is now materialized once per runner and the + `&[char]` threaded through `step`; the budget is charged before that collection + so a cancelled/expired run aborts without materializing a large input. +- **The HTTP admission slot spans the full request lifetime.** The guard used to + drop when `WaitForRequest` returned (reopening the gate at dequeue). It now + rides with the parked response (`PendingResponse`) and moves into the + `ResponseCompletion`, releasing only on respond / prune / shutdown. +- **Every admitted WebSocket Connect gets a paired Disconnect.** The disconnect + event is delivered with a blocking `send().await` (was a lossy `try_send`), so + a momentarily-full queue can't drop the only per-connection cleanup event. +- **The run budget is task-local, not thread-local.** `Interpreter` is a public, + `!Send` re-export, so an embedder can `join!`/`spawn_local` two runs on one + thread; a thread-local held across `.await` cross-contaminated them. A + `tokio::task_local!` scope (`ExecutionBudget::scope`) wraps each run and each + REPL command; the retained thread-local `enter` is only a synchronous fallback + for the single-future CLI front-end, always shadowed by an async scope. +- **The lexer and recursive expression work are checkpointed.** The lexer polls + every 4096 tokens (cooperative early-stop); the parser polls at every + primary-expression parse (strided); `analyze_expression` and + `infer_expression_type` poll per node (latched) — so one huge expression is + interruptible, not just statement boundaries. +- **The large interpreter stack is a public embedder helper.** + `wfl::run_with_interpreter_stack` (and `INTERPRETER_STACK_SIZE`) encapsulate the + 1 GiB stack the CLI uses; the CLI now shares it, and `Interpreter` documents + that embedders must use it (or a low `max_call_depth`) so the depth limit fires + before a native stack overflow. + +## Sixth review round (issue #611) + +A final deep review found two P1 merge blockers in the fifth round's own +remediation, plus three correctness/API residuals. All five are fixed here, each +with a regression test. + +- **The lexer returns a typed fatal outcome, never a truncated success.** The + fifth round's strided lexer checkpoint `break`'d on a budget breach and + returned the tokens gathered so far — so `wfl --lex` wrote a partial dump and + exited 0, and a prefix that happened to end at a statement boundary could + parse and even execute. The loop body is now a private `lex_positions_core` + parameterised by 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), 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`, and the REPL) + propagates it. At each stride the deadline and cancellation are checked + **directly** — not through `charge_operation`'s 1024-op sampling, which nested + inside the 4096-token stride could postpone them by millions of tokens — and + the operation ceiling is charged separately. +- **A timed-out pending HTTP request no longer wedges admission.** The fifth + round parked the admission guard in the interpreter's `pending_responses` map, + where a closed entry was pruned only when a *later* dequeued request was + inserted. Once the cap filled with dequeued-but-unanswered requests, every + route task timed out but no new request could be admitted to trigger the + prune, so the slots stayed pinned forever. The guard now rides with the warp + transport future, which stays alive awaiting the response even after the + interpreter dequeues the request — 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 still-queued bodies. A live-server regression fills the cap with + unanswered requests, waits for the timeout, and proves admission reopens with + no new request first. +- **An analyzer entry-time breach is recorded on the typed channel.** A breach at + `analyze`'s phase-boundary checkpoint rendered a `SemanticError` but left + `budget_error` unset, so `TypeChecker::new()`'s `take_budget_error()` saw + nothing and misclassified it as `TypeCheckError::Types`. The entry branch now + stores the typed breach before returning, matching `analyze_statement`. Tested + with an already-cancelled budget so the entry (not a statement) fires. +- **Unicode lookbehind stays in character indices.** The refactored VM made + `MatchResult::end` a character index, but the full-slice lookbehind test still + compared it against `text_slice.len()` (bytes), inverting positive/negative + lookbehind the moment the window held a multibyte char. It now compares against + the slice's character length (`start_offset`). The public `CompiledPattern::find` + doc example, which sliced `&text[m.start..m.end]` with those character offsets, + is fixed to extract by chars. Positive and negative Unicode lookbehind tests + pin both directions over a 2-byte `é`. +- **The default public interpreter path is stack-safe.** `Interpreter::new()` — the + zero-config path that promises nothing about its thread stack — capped + recursion at the config default of 1000, which only stays catchable on the + CLI's 1 GiB stack; on an ordinary stack a deep program aborted before the guard + could fire. `new()` now caps at the conservative `DEFAULT_EMBED_CALL_DEPTH` + (12, verified to fire cleanly below the debug overflow point on an 8 MiB stack), + while `with_config`/`with_config_and_budget` honor the configured depth so the + CLI still reaches 1000 on its large stack. An ordinary-stack embedding test + proves the default path returns a catchable call-depth error instead of + aborting. + +## Notes / Follow-ups + +- The outbound `reqwest` client still has no per-request timeout or in-flight + cap; that is a separate audit item (the budget's `max_pending_requests` + covers the *inbound* accepted-request queue). +- The 1 GiB interpreter stack (now exposed via `run_with_interpreter_stack`) is a + pragmatic fix for the interpreter's async-recursion stack cost; the deeper fix + (trampolining the eval loop) is still out of scope here. diff --git a/Docs/04-advanced-features/web-servers.md b/Docs/04-advanced-features/web-servers.md index 21a2b8a9..41f33356 100644 --- a/Docs/04-advanced-features/web-servers.md +++ b/Docs/04-advanced-features/web-servers.md @@ -1027,9 +1027,14 @@ end check - **Single request handling:** Each `wait for request` handles one request - **Blocking:** Server handles requests sequentially (TLS handshakes are concurrent, but your responses are serialized) - **Bounded accept queue:** Because handlers are serial, incoming requests queue up behind the one being handled. That queue is bounded (default 256, configurable via `web_server_request_queue_bound`). When it is full, the server sheds new requests with a `503 Service Unavailable` (plus a `Retry-After` header) and logs a warning, rather than growing memory without bound. See [Configuration Reference](../reference/configuration-reference.md#web_server_request_queue_bound). +- **Bounded response size:** A handler cannot `respond with` a body larger than `web_server_max_response_size` (default 64 MiB); an oversized response is refused with a runtime error rather than streamed unbounded. See [Configuration Reference](../reference/configuration-reference.md#web_server_max_response_size). +- **Bounded request body (chunked-safe):** The request-body limit (`web_server_max_body_size`) is enforced *while the body streams in*, so a chunked upload with no `Content-Length` is bounded too — an oversized body is refused with `413 Payload Too Large` without being fully buffered. +- **Global in-flight cap + request deadline:** The accepted-request cap is shared across every `listen` server via one budget, and one deadline (`web_server_response_timeout_seconds`, default 300s) is set at admission and covers the whole accepted-request lifetime. A body that is not fully received in time is shed with `408 Request Timeout` (so a slow "trickle" upload under the size cap cannot pin a slot), and a handler that does not answer in time is shed with `504 Gateway Timeout`. A shed or abandoned request is skipped and its bookkeeping pruned rather than run as zombie work. - **No middleware system** (yet) - Implement manually - **No built-in session management** - Implement yourself +All of these ceilings, together with the request timeout and body-size limits, are part of one shared [execution budget](../reference/configuration-reference.md#execution-budget-resource-limits). + ### Workarounds **For multiple requests:** Use loops (requires signal handling for shutdown) @@ -1159,6 +1164,33 @@ wait for 3600 seconds close server chat_server ``` +### WebSocket resource limits + +WebSocket queues and connections are bounded — by **count and by bytes** — so a +flood or a slow client cannot grow memory without bound: + +- **Queue bound** (`web_socket_queue_bound`, default 1024): the per-connection + outbound frame queue and the per-server event queue are bounded by frame + *count*. When a queue is full, the extra frame/event is dropped and a warning + is logged. +- **Per-message size** (`web_socket_max_message_size`, default 1 MiB): a single + inbound or outbound text frame larger than this is dropped (with a warning) + rather than queued, so the count bound above also bounds each frame's size. +- **Global queued bytes** (`web_socket_max_queued_bytes`, default 16 MiB): every + queued payload reserves its byte length against one global ceiling and releases + it when the frame is delivered, consumed, or shed — bounding total buffered + WebSocket memory across all connections at once. +- **Connection limit** (`web_socket_max_connections`, default 1024): a + connection attempt beyond the limit is refused with a close frame and a logged + warning. + +`close server` also terminates each live connection deterministically: it signals +every connection to stop reading (so a peer that ignores the close handshake +cannot keep a socket task or its connection slot alive), sends a close frame, and +tears the socket down after a bounded close-handshake timeout. + +All of these are part of the shared [execution budget](../reference/configuration-reference.md#execution-budget-resource-limits). + ## Security Considerations ⚠️ **Important:** Web servers expose your application to the internet. Always: diff --git a/Docs/reference/configuration-reference.md b/Docs/reference/configuration-reference.md index 0c66e30b..0798d33b 100644 --- a/Docs/reference/configuration-reference.md +++ b/Docs/reference/configuration-reference.md @@ -210,8 +210,35 @@ All keys currently loaded from config files, with defaults. | `web_server_bind_address` | IP string | `127.0.0.1` | Bind address for `listen on port` | | `web_server_tls_cert_file` | path | *(none)* | Default PEM cert for bare `listen … secured` | | `web_server_tls_key_file` | path | *(none)* | Default PEM key for bare `listen … secured` | -| `web_server_max_body_size` | integer ≥ 1 | `1048576` (1 MiB) | Max HTTP request body size (bytes) | +| `web_server_max_body_size` | integer ≥ 1 | `1048576` (1 MiB) | Max HTTP request body size (bytes); enforced while streaming (chunked-safe) | +| `web_server_max_response_size` | integer ≥ 1 | `67108864` (64 MiB) | Max HTTP response body size (bytes) | | `web_server_request_queue_bound` | integer ≥ 1 | `256` | Max queued HTTP requests before shedding with 503 | +| `web_server_response_timeout_seconds` | integer ≥ 0 | `300` | Seconds to await a handler before shedding with 504; `0` disables | +| `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 | +| `web_socket_max_message_size` | integer ≥ 1 | `1048576` (1 MiB) | Max size of a single WebSocket text message (bytes); larger frames are dropped | +| `web_socket_max_queued_bytes` | integer ≥ 1 | `16777216` (16 MiB) | Global ceiling on queued WebSocket payload bytes across all connections | + +### Execution budget keys (summary) + +A single [`ExecutionBudget`](#execution-budget-resource-limits) governs every +resource ceiling as one coherent mechanism. These keys tune it (detailed below). +Each is chosen so ordinary programs never trip it while runaway behavior gets a +clean, catchable error instead of a crash or unbounded memory growth. + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `max_operations` | integer ≥ 0 | `0` (unlimited) | Hard ceiling on interpreter operations; `0` disables it | +| `max_call_depth` | integer ≥ 1 | `1000` | Max WFL call/recursion depth | +| `max_import_depth` | integer ≥ 1 | `64` | Max nested `load module` / `include` depth | +| `max_execute_file_depth` | integer ≥ 1 | `4` | Max `execute file` nesting depth | +| `max_pattern_steps` | integer ≥ 1 | `5000000` | Max pattern-matching transitions per match (ReDoS guard) | +| `max_pattern_states` | integer ≥ 1 | `10000` | Max simultaneously-active pattern states per match | +| `max_source_size` | integer ≥ 1 | `67108864` (64 MiB) | Max WFL source-file size (bytes) | + +The wall-clock deadline (`timeout_seconds`), request body/response ceilings, and +the HTTP/WebSocket queue and connection bounds above are all part of the same +budget. --- @@ -495,6 +522,127 @@ Maximum number of accepted-but-not-yet-handled HTTP requests held in the queue b Because request handlers run one at a time (see [Web Servers → Limitations](../04-advanced-features/web-servers.md#limitations--notes)), a burst of traffic queues up behind the handler. Without a bound, that queue could grow until the process runs out of memory. When the queue is full, the server **sheds** further requests with a `503 Service Unavailable` (and a `Retry-After` header) and logs a warning, instead of buffering unbounded work. Raise it to absorb larger bursts at the cost of more memory; lower it to shed sooner under load. A value of `0` is rejected (the default is kept). +#### `web_server_max_response_size` + +Maximum HTTP response body a handler may `respond with`, in bytes. A larger response is refused (the handler gets a runtime error) rather than streaming an unbounded payload to the client. + +- **Type:** Integer (bytes, at least 1) +- **Default:** `67108864` (64 MiB) +- **Example:** `web_server_max_response_size = 5242880` # 5 MiB + +#### `web_server_response_timeout_seconds` + +Maximum time, in seconds, the transport waits for a handler to answer an accepted request before shedding it with a `504 Gateway Timeout` and freeing its in-flight slot. This bounds a dequeued-but-never-answered request so it cannot pin an in-flight slot indefinitely. + +- **Type:** Integer (0 or more) +- **Default:** `300` +- **Example:** `web_server_response_timeout_seconds = 30` + +A value of `0` disables the timeout. The in-flight request cap (`web_server_request_queue_bound`) is enforced globally across every `listen` server via one shared budget, and a request's slot is held from the moment its body starts streaming until the handler responds, this timeout fires, or the client disconnects. + +#### `web_socket_queue_bound` + +Maximum number of queued frames (per outbound connection) and lifecycle events (per server) held for a WebSocket before shedding. Bounds WebSocket memory the same way `web_server_request_queue_bound` bounds HTTP requests: when a channel is full, the extra frame/event is dropped and a warning is logged, instead of growing memory without bound. + +- **Type:** Integer (at least 1) +- **Default:** `1024` +- **Example:** `web_socket_queue_bound = 4096` + +#### `web_socket_max_connections` + +Maximum number of simultaneous live WebSocket connections. A connection attempt beyond the limit is refused (the server sends a close frame and logs a warning) instead of registering unbounded connections. + +- **Type:** Integer (at least 1) +- **Default:** `1024` +- **Example:** `web_socket_max_connections = 256` + +#### `web_socket_max_message_size` + +Maximum size in bytes of a single WebSocket text message, applied to both inbound frames and outbound `send`/`broadcast` frames. A larger frame is dropped (with a warning) rather than queued, so the per-message memory a connection can pin is bounded — the frame-count bound (`web_socket_queue_bound`) alone does not bound the *size* of each queued frame. + +- **Type:** Integer (at least 1) +- **Default:** `1048576` (1 MiB) +- **Example:** `web_socket_max_message_size = 262144` + +#### `web_socket_max_queued_bytes` + +Global ceiling in bytes on all WebSocket payloads queued across every connection's inbound event and outbound frame channels at once. Each queued frame reserves its byte length against this ceiling and releases it when the frame is delivered, consumed, or shed, so a slow or absent consumer cannot buffer WebSocket memory without bound even under the per-message and per-channel count limits. + +- **Type:** Integer (at least 1) +- **Default:** `16777216` (16 MiB) +- **Example:** `web_socket_max_queued_bytes = 8388608` + +### Execution budget (resource limits) + +WFL enforces every resource ceiling through a single shared **execution budget** +object that travels with a run through parsing, evaluation, pattern matching, web +handling, and module loading. Consolidating these caps in one place means they +behave consistently and are tuned from one section of `.wflcfg`. The wall-clock +deadline is `timeout_seconds` (above); the byte and queue ceilings are the +`web_server_*` / `web_socket_*` keys (above). The remaining knobs: + +#### `max_operations` + +Hard ceiling on the number of interpreter operations a run may execute. This is a belt-and-suspenders guard against a program that spins without ever awaiting (which the wall-clock `timeout_seconds` may not catch promptly inside a tight loop). + +- **Type:** Integer (0 or more) +- **Default:** `0` (unlimited — matches historic behavior) +- **Example:** `max_operations = 500000000` + +A value of `0` disables the ceiling. Like `timeout_seconds`, this ceiling is **not** enforced inside a `main loop` (a long-lived server would otherwise stop after N operations); cooperative cancellation still applies. + +#### `max_call_depth` + +Maximum WFL call/recursion depth. When exceeded, the run stops with a clean, catchable *“Maximum call depth (N) exceeded — possible infinite recursion”* error instead of a native stack overflow that would abort the whole process. + +- **Type:** Integer (at least 1) +- **Default:** `1000` +- **Example:** `max_call_depth = 2000` + +WFL runs the interpreter on a large (1 GiB) stack so this depth is reached safely; if you raise the ceiling substantially and rely on very deep recursion, prefer an iterative formulation where practical. + +#### `max_import_depth` + +Maximum nesting depth of `load module` / `include from`. Circular imports are already detected separately; this bounds a legitimately deep — but likely accidental — dependency chain. + +- **Type:** Integer (at least 1) +- **Default:** `64` +- **Example:** `max_import_depth = 128` + +#### `max_execute_file_depth` + +Maximum nesting depth of `execute file` runs. Kept small because each level re-enters the whole interpreter recursively. + +- **Type:** Integer (at least 1) +- **Default:** `4` +- **Example:** `max_execute_file_depth = 6` + +#### `max_pattern_steps` + +Maximum number of pattern-VM transitions a single match attempt may take (Regular-expression Denial-of-Service, “ReDoS”, guard). A pathological pattern that would otherwise run away stops with a pattern step-limit error. + +- **Type:** Integer (at least 1) +- **Default:** `5000000` +- **Example:** `max_pattern_steps = 250000` + +#### `max_pattern_states` + +Maximum number of simultaneously-active states a single pattern match may hold. Bounds exponential state fan-out that step-counting alone does not catch. + +- **Type:** Integer (at least 1) +- **Default:** `10000` +- **Example:** `max_pattern_states = 50000` + +#### `max_source_size` + +Maximum size, in bytes, of a WFL source file. A larger file is refused before it is lexed or parsed. + +- **Type:** Integer (bytes, at least 1) +- **Default:** `67108864` (64 MiB) +- **Example:** `max_source_size = 1048576` # 1 MiB + +Each of these positive-integer keys rejects `0` and non-numeric values, keeping the default and logging a warning. + --- ## How config relates to lint, style, and servers diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index e3a899ae..1c491c79 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -228,6 +228,13 @@ pub struct Analyzer { /// count loops reusing the same variable name are reported as errors, /// while shadowing an ordinary outer variable is allowed. active_loop_variables: Vec, + /// The shared-budget breach hit mid-traversal, if any. Its presence latches + /// the recursive `analyze_statement` checkpoint (record once, then + /// short-circuit every remaining node instead of pushing a duplicate error + /// per nested statement) and lets callers recover the *typed* breach — a + /// budget failure is fatal and must never be mistaken for an ordinary + /// semantic diagnostic. Reset per `analyze` run. + budget_error: Option, } impl Default for Analyzer { @@ -432,6 +439,7 @@ impl Analyzer { has_includes: false, try_depth: 0, active_loop_variables: Vec::new(), + budget_error: None, } } @@ -481,6 +489,31 @@ impl Analyzer { } pub fn analyze(&mut self, program: &Program) -> Result<(), Vec> { + // Reset the per-run budget breach so a reused analyzer never carries a + // stale one from a previous program (matches the direct assignment of + // `has_includes` below). + self.budget_error = None; + + // Front-end budget checkpoint at the analysis phase boundary. `analyze` + // backs the type checker and every `load module` / `include` / + // `execute file`, so consulting the run budget here (deadline/ + // cancellation, exemption-aware) keeps those nested pipelines cooperative + // rather than only the top-level interpret. + if let Some(budget) = crate::exec::budget::ExecutionBudget::current() + && let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) + { + // Record the typed breach on the fatal channel BEFORE returning, so a + // caller that consults `take_budget_error()` (e.g. `TypeChecker::new()` + // entering with an already-exhausted/cancelled budget) sees an + // entry-time breach as the fatal `Budget` variant rather than + // misclassifying its rendered `SemanticError` as ordinary type errors. + // `analyze_statement` sets this on the recursive path; the phase + // boundary must too. + let rendered = SemanticError::new(exceeded.message(), 0, 0); + self.budget_error = Some(exceeded); + return Err(vec![rendered]); + } + // Detect include statements up front. Includes are resolved at runtime // and can expose actions/variables the analyzer never sees, so their // presence relaxes undefined-action reporting (see `has_includes`). @@ -516,6 +549,15 @@ impl Analyzer { &self.warnings } + /// Take the shared-budget breach recorded during analysis, if any. When + /// `analyze` returns `Err`, a caller must consult this to tell a fatal + /// deadline/cancellation/resource breach apart from ordinary semantic + /// diagnostics — the breach is fatal and its `Vec` form is + /// only a rendering of the same event. + pub fn take_budget_error(&mut self) -> Option { + self.budget_error.take() + } + /// Report an undefined-name reference. Inside a `try` body this is a /// warning rather than a fatal error: the reference raises a catchable /// runtime error, which is documented behavior that programs rely on. @@ -560,6 +602,27 @@ impl Analyzer { } fn analyze_statement(&mut self, statement: &Statement) { + // Recursive front-end checkpoint. The entry poll in `analyze` fires once, + // but this method recurses through every nested block, loop, `try`, + // action, and container-method body — so a single deeply nested + // top-level statement could otherwise run the analyzer to completion + // without honoring the run budget. Polling here (mirroring the parser's + // per-`parse_statement` placement) keeps the whole traversal cooperative + // with the deadline/cancellation/operation limits. Once exhausted, the + // flag short-circuits every remaining node so the breach is recorded a + // single time rather than duplicated per statement. + if self.budget_error.is_some() { + return; + } + if let Some(budget) = crate::exec::budget::ExecutionBudget::current() + && let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) + { + self.errors + .push(SemanticError::new(exceeded.message(), 0, 0)); + self.budget_error = Some(exceeded); + return; + } + match statement { Statement::VariableDeclaration { name, @@ -2440,6 +2503,22 @@ impl Analyzer { } fn analyze_expression(&mut self, expression: &Expression) { + // Recursive front-end checkpoint for expressions. `analyze_statement` + // polls per statement, but one statement can hold an arbitrarily large + // expression tree (a huge list/map literal, a long operator chain), so + // poll here too. The `budget_error` latch records the breach once and + // short-circuits the rest of the traversal. + if self.budget_error.is_some() { + return; + } + if let Some(budget) = crate::exec::budget::ExecutionBudget::current() + && let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) + { + self.errors + .push(SemanticError::new(exceeded.message(), 0, 0)); + self.budget_error = Some(exceeded); + return; + } match expression { Expression::AwaitExpression { expression, diff --git a/src/analyzer/static_analyzer.rs b/src/analyzer/static_analyzer.rs index bdc47d4a..26062244 100644 --- a/src/analyzer/static_analyzer.rs +++ b/src/analyzer/static_analyzer.rs @@ -390,6 +390,17 @@ impl StaticAnalyzer for Analyzer { fn analyze_static(&mut self, program: &Program, file_id: usize) -> Vec { let mut diagnostics = Vec::new(); + // Front-end budget checkpoint: honor the run's deadline/cancellation at + // the analysis phase boundary (via the current-thread budget), so a run + // that has already blown its deadline during parsing/lexing is refused + // here instead of proceeding — `--analyze` consults the budget too. + if let Some(budget) = crate::exec::budget::ExecutionBudget::current() + && let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) + { + diagnostics.push(WflDiagnostic::error(exceeded.message())); + return diagnostics; + } + // Collect all action parameters to filter out errors related to them let mut action_parameters = HashSet::new(); for statement in &program.statements { diff --git a/src/config.rs b/src/config.rs index be505160..66340afa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -53,6 +53,53 @@ pub struct WflConfig { /// server sheds new requests with a 503 instead of growing memory without /// bound. Default 256; must be at least 1. pub web_server_request_queue_bound: usize, + /// Maximum HTTP response body size in bytes. A handler that tries to send a + /// larger body is refused with a 500 rather than streaming an unbounded + /// payload. Feeds `ExecutionBudget`. Default 64 MiB. + pub web_server_max_response_size: usize, + /// Maximum seconds the transport waits for a handler to answer an accepted + /// HTTP request before shedding it with 504 and releasing its in-flight + /// slot. `0` disables the timeout. Feeds `ExecutionBudget`. Default 300. + pub web_server_response_timeout_seconds: u64, + // --- Shared ExecutionBudget limits (see src/exec/budget.rs) --- + /// Hard ceiling on charged interpreter operations. `None`/`0` = unlimited + /// (the default, matching historic behavior). Feeds `ExecutionBudget`. + pub max_operations: Option, + /// Maximum WFL call/recursion depth before a clean error replaces a native + /// stack overflow. Feeds `ExecutionBudget`. Default 1000. + pub max_call_depth: usize, + /// Maximum nested `load module` / `include` depth. Feeds `ExecutionBudget`. + /// Default 64. + pub max_import_depth: usize, + /// Maximum `execute file` nesting depth. Kept small because each level + /// re-enters the whole interpreter recursively. Feeds `ExecutionBudget`. + /// Default 4. + pub max_execute_file_depth: usize, + /// Maximum pattern-VM transitions (instructions) per match operation (ReDoS + /// guard). Feeds `ExecutionBudget`. Default 5000000. + pub max_pattern_steps: usize, + /// Maximum simultaneously-active pattern-VM states per match attempt. Feeds + /// `ExecutionBudget`. Default 10000. + pub max_pattern_states: usize, + /// Maximum WFL source-file size in bytes. Feeds `ExecutionBudget`. + /// Default 64 MiB. + pub max_source_size: usize, + /// Maximum queued frames/events per WebSocket channel before shedding. + /// Feeds `ExecutionBudget`. Default 1024; must be at least 1. + pub web_socket_queue_bound: usize, + /// Maximum simultaneous live WebSocket connections. Feeds `ExecutionBudget`. + /// Default 1024; must be at least 1. + pub web_socket_max_connections: usize, + /// Maximum size in bytes of a single WebSocket text message (inbound or + /// outbound); larger frames are dropped rather than queued. Feeds + /// `ExecutionBudget`. Default 1 MiB; must be at least 1. + pub web_socket_max_message_size: usize, + /// Global ceiling in bytes on WebSocket payloads queued across every + /// connection's inbound event and outbound frame channels; reservations are + /// released as frames are consumed or shed, so a slow/absent consumer cannot + /// buffer without bound. Feeds `ExecutionBudget`. Default 16 MiB; must be at + /// least 1. + pub web_socket_max_queued_bytes: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -136,6 +183,25 @@ impl Default for WflConfig { // Bound the accept queue so a flood sheds with 503 rather than // growing memory without bound. Aligns with the Phase 1 in-flight cap. web_server_request_queue_bound: 256, + // 64 MiB default response body limit (DoS protection). + web_server_max_response_size: 64 * 1024 * 1024, + // Free an accepted request's in-flight slot if its handler does not + // answer within 5 minutes (far longer than any serial handler needs). + web_server_response_timeout_seconds: 300, + // Shared ExecutionBudget limits (see src/exec/budget.rs). Defaults + // are chosen so existing programs never trip them while runaway + // behavior gets a clean error instead of a crash or OOM. + max_operations: None, + max_call_depth: 1_000, + max_import_depth: 64, + max_execute_file_depth: 4, + max_pattern_steps: 5_000_000, + max_pattern_states: 10_000, + max_source_size: 64 * 1024 * 1024, + web_socket_queue_bound: 1_024, + web_socket_max_connections: 1_024, + web_socket_max_message_size: 1_048_576, + web_socket_max_queued_bytes: 16 * 1_048_576, } } } @@ -182,6 +248,22 @@ impl LogLevel { } } +/// Parse a positive-integer `.wflcfg` value into `field`. Rejects zero and +/// non-numeric input with a warning, leaving `field` at its previous value. +/// Shared by the `ExecutionBudget` limit keys, which all require `>= 1`. +fn set_positive_usize(field: &mut usize, key: &str, value: &str, file: &Path) { + match value.parse::() { + Ok(0) | Err(_) => log::warn!( + "Invalid {key} '{value}' in {}: expected a positive integer", + file.display() + ), + Ok(parsed) => { + *field = parsed; + log::debug!("Loaded {key}: {parsed} from {}", file.display()); + } + } +} + fn parse_config_text(config: &mut WflConfig, text: &str, file: &Path) { log::debug!("Parsing config from {}", file.display()); for line in text.lines() { @@ -701,6 +783,105 @@ fn parse_config_text(config: &mut WflConfig, text: &str, file: &Path) { ); } } + "web_server_max_response_size" => match value.parse::() { + 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_response_timeout_seconds" => match value.parse::() { + Ok(secs) => { + // 0 disables the timeout (the documented sentinel). + config.web_server_response_timeout_seconds = secs; + log::debug!( + "Loaded web_server_response_timeout_seconds: {secs} from {}", + file.display() + ); + } + Err(_) => log::warn!( + "Invalid web_server_response_timeout_seconds '{}' in {}: expected a non-negative integer", + value, + file.display() + ), + }, + "max_operations" => match value.parse::() { + Ok(n) => { + // 0 means "no operation ceiling" (the default). + config.max_operations = if n == 0 { None } else { Some(n) }; + log::debug!( + "Loaded max_operations: {:?} from {}", + config.max_operations, + file.display() + ); + } + Err(_) => log::warn!( + "Invalid max_operations '{}' in {}: expected a non-negative integer", + value, + file.display() + ), + }, + "max_call_depth" => { + set_positive_usize(&mut config.max_call_depth, "max_call_depth", value, file) + } + "max_import_depth" => set_positive_usize( + &mut config.max_import_depth, + "max_import_depth", + value, + file, + ), + "max_execute_file_depth" => set_positive_usize( + &mut config.max_execute_file_depth, + "max_execute_file_depth", + value, + file, + ), + "max_pattern_steps" => set_positive_usize( + &mut config.max_pattern_steps, + "max_pattern_steps", + value, + file, + ), + "max_pattern_states" => set_positive_usize( + &mut config.max_pattern_states, + "max_pattern_states", + value, + file, + ), + "max_source_size" => { + set_positive_usize(&mut config.max_source_size, "max_source_size", value, file) + } + "web_socket_queue_bound" => set_positive_usize( + &mut config.web_socket_queue_bound, + "web_socket_queue_bound", + value, + file, + ), + "web_socket_max_connections" => set_positive_usize( + &mut config.web_socket_max_connections, + "web_socket_max_connections", + value, + file, + ), + "web_socket_max_message_size" => set_positive_usize( + &mut config.web_socket_max_message_size, + "web_socket_max_message_size", + value, + file, + ), + "web_socket_max_queued_bytes" => set_positive_usize( + &mut config.web_socket_max_queued_bytes, + "web_socket_max_queued_bytes", + value, + file, + ), _ => { log::warn!("Unknown configuration key: {} in {}", key, file.display()); } diff --git a/src/exec/budget.rs b/src/exec/budget.rs new file mode 100644 index 00000000..0498768c --- /dev/null +++ b/src/exec/budget.rs @@ -0,0 +1,1376 @@ +//! A single [`ExecutionBudget`] shared across parsing, evaluation, pattern +//! matching, web handling, and module loading. +//! +//! # Why one object +//! +//! Before this, the runtime enforced a dozen unrelated ceilings from a dozen +//! unrelated places: the interpreter's wall-clock timeout (`max_duration` + +//! `op_count`), the pattern VM's `MAX_STEPS`, the web server's +//! `web_server_max_body_size` and `web_server_request_queue_bound`, an +//! `execute file` depth constant, and several things that were simply +//! *unbounded* (recursion depth, WebSocket queues and connection counts, HTTP +//! response size, source-file size). Each was a separate audit finding. +//! +//! `ExecutionBudget` replaces that scatter with one coherent object. It carries +//! the immutable [`BudgetLimits`] for a run plus the small amount of shared +//! mutable accounting (operations charged, live pending requests, live +//! WebSocket connections) needed to enforce them. Every dimension the task +//! enumerates lives here: +//! +//! * **Deadline and cancellation** — [`ExecutionBudget::charge_operation`] / +//! [`ExecutionBudget::check_deadline`] / [`ExecutionBudget::cancel`]. +//! * **Remaining interpreter operations** — the operation counter and its +//! optional ceiling. +//! * **Recursion and import depth** — [`ExecutionBudget::check_call_depth`], +//! [`ExecutionBudget::check_import_depth`], +//! [`ExecutionBudget::check_execute_file_depth`]. +//! * **Pattern transitions and active states** — +//! [`ExecutionBudget::check_pattern_steps`] / +//! [`ExecutionBudget::check_pattern_states`]. +//! * **Source, body, and response bytes** — +//! [`ExecutionBudget::check_source_bytes`], +//! [`ExecutionBudget::check_request_body_bytes`], +//! [`ExecutionBudget::check_response_bytes`]. +//! * **Pending HTTP requests** — [`ExecutionBudget::max_pending_requests`]. +//! * **WebSocket queue and connection limits** — +//! [`ExecutionBudget::ws_queue_bound`] / +//! [`ExecutionBudget::try_acquire_ws_connection`]. +//! +//! # Thread-safety +//! +//! The interpreter core stays `!Send` (`Rc`/`RefCell`), but the budget must +//! also be readable from the multi-threaded web transport (warp accept tasks, +//! per-connection WebSocket tasks). It is therefore `Send + Sync`: every +//! mutable field is an atomic, so an `Arc` can be cloned into a +//! transport task without any `Rc`/`RefCell` crossing a thread boundary. Sharing +//! a small atomic-only object this way does not violate the "no `Rc`→`Arc` +//! rewrite of the interpreter" rule — it is exactly how `Arc` is +//! already shared. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use crate::config::WflConfig; + +/// How often (in charged operations) the wall-clock deadline and cancellation +/// flag are sampled on the interpreter hot path. Reading the clock on every +/// operation is a measurable cost in tight loops, so those checks run only on +/// this stride — preserving the interpreter's historic `op_count & 1023` +/// throttle. Must stay a power of two so `index & (STRIDE - 1)` is exact. +const CLOCK_SAMPLE_STRIDE: u64 = 1024; + +/// Immutable per-run ceilings. Construct via [`BudgetLimits::from_config`] (maps +/// the existing `.wflcfg` keys) or [`BudgetLimits::default`]. +/// +/// The two "opt-in" fields ([`BudgetLimits::max_duration`] and +/// [`BudgetLimits::max_operations`]) are `Option`; `None` means "no limit". +/// Every other field is a concrete ceiling that is always enforced — its +/// default is chosen generously so existing programs never trip it while +/// runaway behaviour still gets a clean error instead of a crash or unbounded +/// growth. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BudgetLimits { + /// Wall-clock deadline for non-`main loop` execution. `None` disables it. + /// Mapped from `.wflcfg` `timeout_seconds`. + pub max_duration: Option, + /// Hard ceiling on charged interpreter operations. `None` (the default) + /// disables it, matching the historic behaviour where the operation counter + /// only throttled clock reads. Mapped from `.wflcfg` `max_operations` + /// (`0` = unlimited). + pub max_operations: Option, + /// Maximum WFL call/recursion depth. Mapped from `.wflcfg` `max_call_depth`. + pub max_call_depth: usize, + /// Maximum nested `load module` / `include` depth. Mapped from `.wflcfg` + /// `max_import_depth`. + pub max_import_depth: usize, + /// Maximum `execute file` nesting depth. Kept small because each level + /// re-enters the whole interpreter recursively. Mapped from `.wflcfg` + /// `max_execute_file_depth`. + pub max_execute_file_depth: usize, + /// Maximum pattern-VM transitions per match attempt (ReDoS guard). Mapped + /// from `.wflcfg` `max_pattern_steps`. + pub max_pattern_steps: usize, + /// Maximum simultaneously-active pattern-VM states per match attempt. + /// Mapped from `.wflcfg` `max_pattern_states`. + pub max_pattern_states: usize, + /// Maximum WFL source-file size in bytes. Mapped from `.wflcfg` + /// `max_source_size`. + pub max_source_bytes: usize, + /// Maximum accepted HTTP request body size in bytes. Mapped from `.wflcfg` + /// `web_server_max_body_size`. + pub max_request_body_bytes: usize, + /// Maximum HTTP response body size in bytes. Mapped from `.wflcfg` + /// `web_server_max_response_size`. + pub max_response_bytes: usize, + /// Maximum accepted-but-unhandled HTTP requests held in the transport + /// queue. Mapped from `.wflcfg` `web_server_request_queue_bound`. + pub max_pending_requests: usize, + /// Maximum wall-clock time the transport waits for a handler to answer an + /// accepted request before shedding it with 504 and releasing its in-flight + /// slot. `None` disables the timeout. Mapped from `.wflcfg` + /// `web_server_response_timeout_seconds` (`0` = disabled). + pub max_request_duration: Option, + /// Maximum queued frames/events per WebSocket channel. Mapped from + /// `.wflcfg` `web_socket_queue_bound`. + pub max_ws_queue: usize, + /// Maximum simultaneous live WebSocket connections. Mapped from `.wflcfg` + /// `web_socket_max_connections`. + pub max_ws_connections: usize, + /// Maximum size in bytes of a single WebSocket text message (inbound or + /// outbound). Mapped from `.wflcfg` `web_socket_max_message_size`. + pub max_ws_message_bytes: usize, + /// Global ceiling in bytes on WebSocket payloads queued across every + /// connection. Mapped from `.wflcfg` `web_socket_max_queued_bytes`. + pub max_ws_queued_bytes: usize, +} + +impl Default for BudgetLimits { + fn default() -> Self { + Self { + // Matches the historic interpreter default (`timeout_seconds: 60`). + max_duration: Some(Duration::from_secs(60)), + // Off by default: no operation ceiling existed before. + max_operations: None, + // No runtime recursion guard existed before (only a debug assert at + // 10_000). WFL runs on a dedicated 1 GiB stack (see main.rs), and + // 1_000 frames fit comfortably within it in both debug and release, + // so runaway recursion gets a clean error well before the stack + // overflows — while clearing any realistic program's depth. + max_call_depth: 1_000, + // Module/include nesting was unbounded before; 64 is far beyond any + // real dependency chain. + max_import_depth: 64, + // Preserves the previous `MAX_EXECUTE_FILE_DEPTH` constant exactly. + max_execute_file_depth: 4, + // Per-instruction charging (not per-wave), so this is far above the + // old per-wave `MAX_STEPS` (100_000) while still catching runaway + // (e.g. ReDoS) matches that blow past millions of transitions. + max_pattern_steps: 5_000_000, + // Active-state fan-out was unbounded before; 10_000 is generous for + // any non-pathological pattern. + max_pattern_states: 10_000, + // Source size was unchecked before; 64 MiB clears any real program. + max_source_bytes: 64 * 1024 * 1024, + // Preserves the previous `web_server_max_body_size` default (1 MiB). + max_request_body_bytes: 1_048_576, + // Response size was unchecked before; 64 MiB clears any real payload. + max_response_bytes: 64 * 1024 * 1024, + // Preserves the previous `web_server_request_queue_bound` default. + max_pending_requests: 256, + // Bound how long an accepted request may await its handler, so a + // dequeued-but-unanswered request cannot pin its in-flight slot + // forever. 300s is far longer than any serial handler needs. + max_request_duration: Some(Duration::from_secs(300)), + // WebSocket channels were unbounded before; 1_024 clears normal use. + max_ws_queue: 1_024, + // Connection count was uncapped before; 1_024 clears normal use. + max_ws_connections: 1_024, + // Per-message size was unbounded (only frame count was capped); 1 MiB + // clears normal chat/JSON traffic while bounding a single frame. + max_ws_message_bytes: 1_048_576, + // Global queued-byte ceiling across all WS channels; 16 MiB bounds + // total buffered payload regardless of connection/frame counts. + max_ws_queued_bytes: 16 * 1_048_576, + } + } +} + +impl BudgetLimits { + /// Derive the limits from a loaded [`WflConfig`], mapping the existing + /// `.wflcfg` keys (`timeout_seconds`, `web_server_max_body_size`, + /// `web_server_request_queue_bound`) and the budget-specific keys onto their + /// budget fields. Any field the config does not carry keeps its default. + pub fn from_config(config: &WflConfig) -> Self { + Self { + max_duration: Some(Duration::from_secs(config.timeout_seconds)), + max_operations: config.max_operations, + max_call_depth: config.max_call_depth, + max_import_depth: config.max_import_depth, + max_execute_file_depth: config.max_execute_file_depth, + max_pattern_steps: config.max_pattern_steps, + max_pattern_states: config.max_pattern_states, + max_source_bytes: config.max_source_size, + max_request_body_bytes: config.web_server_max_body_size, + max_response_bytes: config.web_server_max_response_size, + max_pending_requests: config.web_server_request_queue_bound.max(1), + max_request_duration: match config.web_server_response_timeout_seconds { + 0 => None, + secs => Some(Duration::from_secs(secs)), + }, + max_ws_queue: config.web_socket_queue_bound.max(1), + max_ws_connections: config.web_socket_max_connections.max(1), + max_ws_message_bytes: config.web_socket_max_message_size.max(1), + max_ws_queued_bytes: config.web_socket_max_queued_bytes.max(1), + } + } + + /// Limits with every ceiling effectively disabled. Used by standalone + /// pattern helpers and tests that must not be constrained by a run budget. + /// Pattern limits keep the historic `MAX_STEPS`/state defaults so a + /// bare [`crate::pattern::PatternVM::new`] still resists ReDoS. + pub fn unlimited() -> Self { + Self { + max_duration: None, + max_operations: None, + max_call_depth: usize::MAX, + max_import_depth: usize::MAX, + max_execute_file_depth: usize::MAX, + max_pattern_steps: 5_000_000, + max_pattern_states: 10_000, + max_source_bytes: usize::MAX, + max_request_body_bytes: usize::MAX, + max_response_bytes: usize::MAX, + max_pending_requests: usize::MAX, + max_request_duration: None, + max_ws_queue: usize::MAX, + max_ws_connections: usize::MAX, + max_ws_message_bytes: usize::MAX, + max_ws_queued_bytes: usize::MAX, + } + } +} + +/// The specific ceiling a run tripped. Callers map this onto their own error +/// type (the interpreter to `RuntimeError`, the pattern VM to `PatternError`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BudgetExceeded { + /// The wall-clock deadline elapsed. + Deadline { limit_secs: u64 }, + /// The run was cancelled cooperatively via [`ExecutionBudget::cancel`]. + Cancelled, + /// The interpreter-operation ceiling was reached. + Operations { limit: u64 }, + /// Call/recursion depth would exceed the ceiling. + CallDepth { limit: usize }, + /// `load module` / `include` nesting would exceed the ceiling. + ImportDepth { limit: usize }, + /// `execute file` nesting would exceed the ceiling. + ExecuteFileDepth { limit: usize }, + /// A pattern match exceeded its transition ceiling. + PatternSteps { limit: usize }, + /// A pattern match exceeded its active-state ceiling. + PatternStates { limit: usize }, + /// A source file exceeded the byte ceiling. + SourceBytes { limit: usize, actual: usize }, + /// An HTTP request body exceeded the byte ceiling. + RequestBodyBytes { limit: usize, actual: usize }, + /// An HTTP response body exceeded the byte ceiling. + ResponseBytes { limit: usize, actual: usize }, + /// The in-flight/pending HTTP request ceiling was reached. + PendingRequests { limit: usize }, + /// The WebSocket connection ceiling was reached. + WsConnections { limit: usize }, +} + +impl BudgetExceeded { + /// A human-facing, Elm-style message describing the breach. + pub fn message(&self) -> String { + match self { + // Preserves the historic interpreter timeout wording verbatim so + // existing timeout diagnostics/tests keep matching. + BudgetExceeded::Deadline { limit_secs } => { + format!("Execution exceeded timeout ({limit_secs}s)") + } + BudgetExceeded::Cancelled => "Execution was cancelled".to_string(), + BudgetExceeded::Operations { limit } => { + format!("Execution exceeded the operation budget ({limit} operations)") + } + BudgetExceeded::CallDepth { limit } => { + format!("Maximum call depth ({limit}) exceeded - possible infinite recursion") + } + BudgetExceeded::ImportDepth { limit } => { + format!("Maximum import depth ({limit}) exceeded - possible circular imports") + } + BudgetExceeded::ExecuteFileDepth { limit } => format!( + "Maximum execute file nesting depth ({limit}) exceeded - possible circular execution" + ), + BudgetExceeded::PatternSteps { limit } => { + format!("Pattern execution step limit exceeded ({limit} steps)") + } + BudgetExceeded::PatternStates { limit } => { + format!("Pattern active-state limit exceeded ({limit} states)") + } + BudgetExceeded::SourceBytes { limit, actual } => { + format!("Source file too large: {actual} bytes (limit: {limit} bytes)") + } + BudgetExceeded::RequestBodyBytes { limit, actual } => { + format!("Request body too large: {actual} bytes (limit: {limit} bytes)") + } + BudgetExceeded::ResponseBytes { limit, actual } => { + format!("Response body too large: {actual} bytes (limit: {limit} bytes)") + } + BudgetExceeded::PendingRequests { limit } => { + format!("Pending request limit reached ({limit} in flight)") + } + BudgetExceeded::WsConnections { limit } => { + format!("WebSocket connection limit reached ({limit} connections)") + } + } + } +} + +impl std::fmt::Display for BudgetExceeded { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message()) + } +} + +impl std::error::Error for BudgetExceeded {} + +/// The shared runtime budget. Cheap to clone as an `Arc`; every method takes +/// `&self` and mutates only atomics. +#[derive(Debug)] +pub struct ExecutionBudget { + limits: BudgetLimits, + started: Instant, + cancelled: AtomicBool, + /// Total interpreter operations charged. Also drives the clock-sampling + /// stride, exactly as the old `op_count` field did. + operations: AtomicU64, + /// Accepted-but-unfinished HTTP requests currently in flight. + pending_requests: AtomicUsize, + /// Live WebSocket connections currently registered. + ws_connections: AtomicUsize, + /// WebSocket payload bytes currently queued across every connection's + /// inbound event and outbound frame channels. Bounded by + /// `limits.max_ws_queued_bytes`; each queued frame holds a [`WsBytePermit`] + /// that releases its bytes when the frame is consumed or shed. + ws_queued_bytes: AtomicUsize, + /// Number of `main loop`s currently active (a *depth*, not a flag). The + /// wall-clock deadline is exempt while this is `> 0` — a long-lived server + /// must not time out on its own uptime. A **depth counter** (rather than a + /// bool) makes the exemption nestable and correct under `execute file`: a + /// child interpreter that shares this budget inherits the parent's active + /// main loop, and an [`MainLoopGuard`] restores the depth on *every* exit, + /// including a caught error. Pattern matching reads this live so a match + /// launched inside a `main loop` gets the same exemption as ordinary + /// operations (see [`ExecutionBudget::charge_operation`]). + main_loop_depth: AtomicUsize, +} + +// NOTE: per-match pattern accounting (transitions + active states) lives on a +// separate per-match [`PatternMeter`], *not* on the shared run budget. Two +// matches that share one `Arc` (e.g. concurrent web handlers) +// must never reset or share a single transition counter, or one could grant the +// other unbounded extra quota. The budget owns only the *limits* and the +// cross-cutting deadline/cancellation the meter samples. + +impl ExecutionBudget { + /// Build a budget from explicit limits, starting the deadline clock now. + pub fn new(limits: BudgetLimits) -> Self { + Self { + limits, + started: Instant::now(), + cancelled: AtomicBool::new(false), + operations: AtomicU64::new(0), + pending_requests: AtomicUsize::new(0), + ws_connections: AtomicUsize::new(0), + ws_queued_bytes: AtomicUsize::new(0), + main_loop_depth: AtomicUsize::new(0), + } + } + + /// Build a budget from a loaded configuration. See + /// [`BudgetLimits::from_config`]. + pub fn from_config(config: &WflConfig) -> Self { + Self::new(BudgetLimits::from_config(config)) + } + + /// A budget with no effective ceilings (pattern ReDoS guards aside). For + /// standalone pattern helpers and tests. + pub fn unlimited() -> Self { + Self::new(BudgetLimits::unlimited()) + } + + /// The immutable limits backing this budget. + pub fn limits(&self) -> &BudgetLimits { + &self.limits + } + + /// Time elapsed since the budget was created. + pub fn elapsed(&self) -> Duration { + self.started.elapsed() + } + + // ----- Deadline & cancellation ----------------------------------------- + + /// Request cooperative cancellation. The next sampled checkpoint (and any + /// [`ExecutionBudget::check_cancelled`] call) observes it. + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Relaxed); + } + + /// Whether cancellation has been requested. + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Relaxed) + } + + /// Fail if cancellation has been requested. Cheap; safe to call anywhere. + pub fn check_cancelled(&self) -> Result<(), BudgetExceeded> { + if self.is_cancelled() { + Err(BudgetExceeded::Cancelled) + } else { + Ok(()) + } + } + + /// Enter a `main loop`: bump the main-loop depth and return an + /// [`MainLoopGuard`] that restores it on drop — on *every* exit path, + /// including a caught error or a nested loop, so the wall-clock exemption is + /// never leaked or cleared early. While the depth is `> 0` the deadline is + /// exempt (a long-lived server must not time out on its own uptime). + pub fn enter_main_loop(self: &Arc) -> MainLoopGuard { + self.main_loop_depth.fetch_add(1, Ordering::AcqRel); + MainLoopGuard { + budget: Arc::clone(self), + } + } + + /// Whether the wall-clock deadline is currently exempt — i.e. at least one + /// `main loop` is active on this (shared) budget. Read live, so a match or + /// operation launched inside a `main loop` is exempt and one launched after + /// it exits is not. + pub fn is_deadline_exempt(&self) -> bool { + self.main_loop_depth.load(Ordering::Acquire) > 0 + } + + /// The number of `main loop`s currently active on this budget. + pub fn main_loop_depth(&self) -> usize { + self.main_loop_depth.load(Ordering::Acquire) + } + + /// Fail if the wall-clock deadline has elapsed. Reads the clock every call; + /// prefer [`ExecutionBudget::charge_operation`] on hot paths, which samples. + pub fn check_deadline(&self) -> Result<(), BudgetExceeded> { + if let Some(limit) = self.limits.max_duration + && self.started.elapsed() > limit + { + return Err(BudgetExceeded::Deadline { + limit_secs: limit.as_secs(), + }); + } + Ok(()) + } + + // ----- Interpreter operations ------------------------------------------ + + /// Charge one interpreter operation. + /// + /// Always: increments the operation counter and, on a throttled stride, + /// honours cancellation. When `enforce_limits` is true it also enforces the + /// operation ceiling (every call) and the wall-clock deadline (on the + /// stride). `enforce_limits` is set to `false` while inside a `main loop`, + /// preserving the historic rule that a long-lived server loop is exempt from + /// the timeout — cancellation still applies so a server can be stopped. + pub fn charge_operation(&self, enforce_limits: bool) -> Result<(), BudgetExceeded> { + // `main loop` exemption: do not consume the operation budget or read the + // clock (a long-lived server would otherwise exhaust the ceiling), but + // still honour cooperative cancellation so the loop can be stopped. The + // operation counter is left untouched so exempt work cannot later push a + // post-loop `Operations` breach. + if !enforce_limits { + if self.cancelled.load(Ordering::Relaxed) { + return Err(BudgetExceeded::Cancelled); + } + return Ok(()); + } + + // `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 let Some(limit) = self.limits.max_operations + && index >= limit + { + return Err(BudgetExceeded::Operations { limit }); + } + + if sample { + if self.cancelled.load(Ordering::Relaxed) { + return Err(BudgetExceeded::Cancelled); + } + if let Some(limit) = self.limits.max_duration + && self.started.elapsed() > limit + { + return Err(BudgetExceeded::Deadline { + limit_secs: limit.as_secs(), + }); + } + } + + Ok(()) + } + + /// Operations charged so far. + pub fn operations_charged(&self) -> u64 { + self.operations.load(Ordering::Relaxed) + } + + // ----- Depth guards ----------------------------------------------------- + + /// Fail if entering another call frame would exceed the recursion ceiling. + /// `current_depth` is the number of frames already on the stack. + pub fn check_call_depth(&self, current_depth: usize) -> Result<(), BudgetExceeded> { + if current_depth >= self.limits.max_call_depth { + Err(BudgetExceeded::CallDepth { + limit: self.limits.max_call_depth, + }) + } else { + Ok(()) + } + } + + /// Fail if entering another import would exceed the import ceiling. + /// `current_depth` is the number of modules already loading. + pub fn check_import_depth(&self, current_depth: usize) -> Result<(), BudgetExceeded> { + if current_depth >= self.limits.max_import_depth { + Err(BudgetExceeded::ImportDepth { + limit: self.limits.max_import_depth, + }) + } else { + Ok(()) + } + } + + /// Fail if entering another `execute file` level would exceed the ceiling. + pub fn check_execute_file_depth(&self, current_depth: usize) -> Result<(), BudgetExceeded> { + if current_depth >= self.limits.max_execute_file_depth { + Err(BudgetExceeded::ExecuteFileDepth { + limit: self.limits.max_execute_file_depth, + }) + } else { + Ok(()) + } + } + + // ----- Pattern matching ------------------------------------------------- + + /// The per-match transition ceiling for the pattern VM. + pub fn pattern_step_limit(&self) -> usize { + self.limits.max_pattern_steps + } + + /// The per-match active-state ceiling for the pattern VM. + pub fn pattern_state_limit(&self) -> usize { + self.limits.max_pattern_states + } + + /// Fail if a pattern match has taken more transitions than allowed. + pub fn check_pattern_steps(&self, steps: usize) -> Result<(), BudgetExceeded> { + if steps > self.limits.max_pattern_steps { + Err(BudgetExceeded::PatternSteps { + limit: self.limits.max_pattern_steps, + }) + } else { + Ok(()) + } + } + + /// Fail if a pattern match holds more active states than allowed. + pub fn check_pattern_states(&self, states: usize) -> Result<(), BudgetExceeded> { + if states > self.limits.max_pattern_states { + Err(BudgetExceeded::PatternStates { + limit: self.limits.max_pattern_states, + }) + } else { + Ok(()) + } + } + + // ----- Byte ceilings ---------------------------------------------------- + + /// The source-file byte ceiling. A bounded loader reads at most this many + /// bytes (plus one) so an oversized file is refused without allocating it. + pub fn max_source_bytes(&self) -> usize { + self.limits.max_source_bytes + } + + /// Fail if a source file exceeds the byte ceiling. `len` is a raw file + /// length (`u64`); a value that does not fit in `usize` (huge file on a + /// 32-bit target) is treated as over the limit rather than truncated. + pub fn check_source_len(&self, len: u64) -> Result<(), BudgetExceeded> { + match usize::try_from(len) { + Ok(len) => self.check_source_bytes(len), + Err(_) => Err(BudgetExceeded::SourceBytes { + limit: self.limits.max_source_bytes, + actual: usize::MAX, + }), + } + } + + /// Fail if a source file exceeds the byte ceiling. + pub fn check_source_bytes(&self, len: usize) -> Result<(), BudgetExceeded> { + if len > self.limits.max_source_bytes { + Err(BudgetExceeded::SourceBytes { + limit: self.limits.max_source_bytes, + actual: len, + }) + } else { + Ok(()) + } + } + + /// Fail if an HTTP request body exceeds the byte ceiling. + pub fn check_request_body_bytes(&self, len: usize) -> Result<(), BudgetExceeded> { + if len > self.limits.max_request_body_bytes { + Err(BudgetExceeded::RequestBodyBytes { + limit: self.limits.max_request_body_bytes, + actual: len, + }) + } else { + Ok(()) + } + } + + /// Fail if an HTTP response body exceeds the byte ceiling. + pub fn check_response_bytes(&self, len: usize) -> Result<(), BudgetExceeded> { + if len > self.limits.max_response_bytes { + Err(BudgetExceeded::ResponseBytes { + limit: self.limits.max_response_bytes, + actual: len, + }) + } else { + Ok(()) + } + } + + /// The accepted HTTP request body ceiling in bytes. + pub fn max_request_body_bytes(&self) -> usize { + self.limits.max_request_body_bytes + } + + // ----- Pending HTTP requests ------------------------------------------- + + /// The pending/in-flight HTTP request ceiling. The web transport sizes its + /// bounded queue and admission semaphore from this value. + pub fn max_pending_requests(&self) -> usize { + self.limits.max_pending_requests + } + + /// The maximum time the transport waits for a handler to answer an accepted + /// request before shedding it (504) and freeing its slot. `None` = no limit. + pub fn max_request_duration(&self) -> Option { + self.limits.max_request_duration + } + + /// Try to reserve a pending-request slot, returning an RAII guard that + /// releases it on drop. `None` when already at the ceiling. Provided for + /// callers that want to account pending requests directly; the web server's + /// bounded queue is the primary enforcement path. + pub fn try_acquire_request(self: &Arc) -> Option { + let limit = self.limits.max_pending_requests; + let mut current = self.pending_requests.load(Ordering::Acquire); + loop { + if current >= limit { + return None; + } + match self.pending_requests.compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + return Some(RequestGuard { + budget: Arc::clone(self), + }); + } + Err(observed) => current = observed, + } + } + } + + /// Pending HTTP requests currently reserved. + pub fn pending_requests(&self) -> usize { + self.pending_requests.load(Ordering::Relaxed) + } + + // ----- WebSocket -------------------------------------------------------- + + /// The per-channel WebSocket queue bound. Transport tasks size their + /// bounded channels from this value and shed on `Full`. + pub fn ws_queue_bound(&self) -> usize { + self.limits.max_ws_queue + } + + /// The maximum size in bytes of a single WebSocket text message; larger + /// frames are dropped rather than queued. + pub fn max_ws_message_bytes(&self) -> usize { + self.limits.max_ws_message_bytes + } + + /// Try to reserve `bytes` of the global WebSocket queued-byte budget for one + /// frame, returning an RAII [`WsBytePermit`] that releases them when the + /// frame is consumed or shed. `None` when the frame alone exceeds + /// `max_ws_message_bytes`, or when reserving would exceed the global + /// `max_ws_queued_bytes` ceiling — in which case the transport sheds it. + pub fn try_reserve_ws_bytes(self: &Arc, bytes: usize) -> Option { + if bytes > self.limits.max_ws_message_bytes { + return None; + } + let limit = self.limits.max_ws_queued_bytes; + let mut current = self.ws_queued_bytes.load(Ordering::Acquire); + loop { + if current.saturating_add(bytes) > limit { + return None; + } + match self.ws_queued_bytes.compare_exchange_weak( + current, + current + bytes, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + return Some(WsBytePermit { + budget: Arc::clone(self), + bytes, + }); + } + Err(observed) => current = observed, + } + } + } + + /// WebSocket payload bytes currently queued across every connection. + pub fn ws_queued_bytes(&self) -> usize { + self.ws_queued_bytes.load(Ordering::Relaxed) + } + + /// Try to reserve a WebSocket connection slot, returning an RAII guard that + /// releases it when the connection ends. `None` when already at the + /// ceiling, in which case the transport should refuse the connection. + pub fn try_acquire_ws_connection(self: &Arc) -> Option { + let limit = self.limits.max_ws_connections; + let mut current = self.ws_connections.load(Ordering::Acquire); + loop { + if current >= limit { + return None; + } + match self.ws_connections.compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + return Some(WsConnectionGuard { + budget: Arc::clone(self), + }); + } + Err(observed) => current = observed, + } + } + } + + /// Live WebSocket connections currently reserved. + pub fn ws_connections(&self) -> usize { + self.ws_connections.load(Ordering::Relaxed) + } +} + +impl Default for ExecutionBudget { + fn default() -> Self { + Self::new(BudgetLimits::default()) + } +} + +tokio::task_local! { + /// The budget in effect for the current async **task** — an interpreter run + /// or a REPL command. Task-local, NOT thread-local, so two interpreter + /// futures interleaved on one thread (a library embedder that `join!`s or + /// `spawn_local`s two `Interpreter`s — both are re-exported from the crate + /// root and are `!Send`, so this is legal) never observe each other's budget + /// or restore stale state across an `.await`. Any async run establishes this + /// scope, so it always takes precedence over the synchronous fallback below. + static CURRENT_BUDGET_TASK: Arc; +} + +thread_local! { + /// Synchronous fallback current budget, consulted only when no task-local + /// scope is active. It exists for code that runs to completion **without + /// awaiting** and cannot interleave — specifically the CLI front-end + /// (lex/parse/analyze/type-check) installed by `main`, which runs on a + /// single-future runtime. Because every async run (`interpret`, REPL + /// `process_line`) wraps itself in a [`ExecutionBudget::scope`] that shadows + /// this, the fallback can never cross-contaminate an interleaved run. + static CURRENT_BUDGET_THREAD: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +impl ExecutionBudget { + /// Run `future` with `budget` installed as the task-local current budget, + /// restoring the previous task-local (if any) when it completes. This is the + /// interleaving-safe way to scope a run; prefer it for every async run. + /// Nesting (e.g. an `execute file` child) is supported. + pub async fn scope(budget: Arc, future: F) -> F::Output + where + F: std::future::Future, + { + CURRENT_BUDGET_TASK.scope(budget, future).await + } + + /// Install `budget` as the synchronous thread-local fallback for the + /// lifetime of the returned guard (restoring the previous one on drop). Use + /// this ONLY for synchronous, non-interleaving contexts (the CLI front-end); + /// async runs must use [`ExecutionBudget::scope`], which takes precedence. + pub fn enter(budget: Arc) -> CurrentBudgetGuard { + let previous = CURRENT_BUDGET_THREAD.with(|c| c.borrow_mut().replace(budget)); + CurrentBudgetGuard { previous } + } + + /// The current budget: the task-local scope if one is active (an async run), + /// otherwise the synchronous thread-local fallback (the CLI front-end). + pub fn current() -> Option> { + CURRENT_BUDGET_TASK + .try_with(Arc::clone) + .ok() + .or_else(|| CURRENT_BUDGET_THREAD.with(|c| c.borrow().clone())) + } + + /// The current budget, or a fresh unlimited one (which still carries the + /// pattern ReDoS ceilings) when no run is active — so a bare + /// [`crate::pattern::PatternVM::new`] is always bounded. + pub fn current_or_default() -> Arc { + Self::current().unwrap_or_else(|| Arc::new(Self::unlimited())) + } +} + +/// Restores the previous synchronous thread-local fallback budget when dropped. +#[must_use] +pub struct CurrentBudgetGuard { + previous: Option>, +} + +impl Drop for CurrentBudgetGuard { + fn drop(&mut self) { + CURRENT_BUDGET_THREAD.with(|c| *c.borrow_mut() = self.previous.take()); + } +} + +/// RAII slot for one in-flight HTTP request; releases on drop. +#[derive(Debug)] +pub struct RequestGuard { + budget: Arc, +} + +impl Drop for RequestGuard { + fn drop(&mut self) { + self.budget.pending_requests.fetch_sub(1, Ordering::AcqRel); + } +} + +/// RAII slot for one live WebSocket connection; releases on drop. +#[derive(Debug)] +pub struct WsConnectionGuard { + budget: Arc, +} + +impl Drop for WsConnectionGuard { + fn drop(&mut self) { + self.budget.ws_connections.fetch_sub(1, Ordering::AcqRel); + } +} + +/// RAII reservation of global WebSocket queued bytes for one queued frame; +/// releases the bytes when the frame is consumed (dequeued and dropped) or shed. +#[derive(Debug)] +pub struct WsBytePermit { + budget: Arc, + bytes: usize, +} + +impl Drop for WsBytePermit { + fn drop(&mut self) { + self.budget + .ws_queued_bytes + .fetch_sub(self.bytes, Ordering::AcqRel); + } +} + +/// RAII marker for one active `main loop`; decrements the shared main-loop depth +/// on drop. Because it restores on *every* exit — normal, early `return`, a +/// caught error unwinding through the loop, or a nested loop — the wall-clock +/// exemption can never leak past the loop or be cleared while an outer loop is +/// still active. +#[derive(Debug)] +#[must_use] +pub struct MainLoopGuard { + budget: Arc, +} + +impl Drop for MainLoopGuard { + fn drop(&mut self) { + self.budget.main_loop_depth.fetch_sub(1, Ordering::AcqRel); + } +} + +/// Per-top-level-match pattern metering. +/// +/// A fresh `PatternMeter` is created for each top-level pattern operation +/// (`matches`/`find`/`find_all`) and cloned (as an `Arc`) **only** into nested +/// lookaround/lookbehind VMs, so their transitions and active states count +/// against the *same* per-match ceilings as the enclosing match. It borrows the +/// run's limits, wall-clock deadline, and cancellation flag from the shared +/// [`ExecutionBudget`], but keeps its own transition counter and active-state +/// accounting — so two matches sharing one run budget (e.g. concurrent web +/// handlers) never reset or share each other's meter. +/// +/// Kept atomic (rather than `Cell`) so a [`crate::pattern::PatternVM`] stays +/// `Send`; a single match runs on one thread, so the atomics are uncontended. +#[derive(Debug)] +pub struct PatternMeter { + budget: Arc, + /// Transitions charged for this match (all frontiers, all nested VMs). + steps: AtomicU64, + /// State slots reserved live across every frontier (current + next + /// generation + any suspended nested lookaround/lookbehind frontiers). + active_states: AtomicUsize, +} + +impl PatternMeter { + /// A fresh per-match meter bound to `budget`. + pub fn new(budget: Arc) -> Arc { + Arc::new(Self { + budget, + steps: AtomicU64::new(0), + active_states: AtomicUsize::new(0), + }) + } + + /// The shared run budget this meter borrows limits/deadline/cancellation from. + pub fn budget(&self) -> &Arc { + &self.budget + } + + /// Reset the per-match counters. Called once at the start of each *direct* + /// top-level VM operation so reusing one VM does not accumulate transitions + /// from an unrelated prior match; nested lookaround VMs share the meter and + /// deliberately do **not** reset it. + pub fn reset(&self) { + self.steps.store(0, Ordering::Relaxed); + self.active_states.store(0, Ordering::Relaxed); + } + + /// Charge one pattern-VM transition (one dispatched instruction). Fails once + /// the per-match transition ceiling is exceeded, and — on a throttled stride + /// — honours cancellation and (unless exempt) the wall-clock deadline, so a + /// single synchronous match cannot run past `timeout_seconds`. A deadline + /// breach surfaces as [`BudgetExceeded::Deadline`] (a timeout), not a step + /// limit. + /// + /// The deadline exemption is read **live** from the shared budget's + /// main-loop depth on each sampled stride (not snapshotted at construction), + /// so reusing one `PatternVM` across a `main loop` boundary is always correct + /// — a match that enters a `main loop` region stops enforcing the deadline + /// and one that leaves it resumes enforcing. + pub fn charge_step(&self) -> Result<(), BudgetExceeded> { + let n = self.steps.fetch_add(1, Ordering::Relaxed); + if n >= self.budget.limits.max_pattern_steps as u64 { + return Err(BudgetExceeded::PatternSteps { + limit: self.budget.limits.max_pattern_steps, + }); + } + if n & (CLOCK_SAMPLE_STRIDE - 1) == 0 { + if self.budget.cancelled.load(Ordering::Relaxed) { + return Err(BudgetExceeded::Cancelled); + } + if !self.budget.is_deadline_exempt() + && let Some(limit) = self.budget.limits.max_duration + && self.budget.started.elapsed() > limit + { + return Err(BudgetExceeded::Deadline { + limit_secs: limit.as_secs(), + }); + } + } + Ok(()) + } + + /// Reserve `n` active-state slots, returning an RAII [`StateReservation`] + /// that releases them on drop. Fails if the total live reservation (this + /// frontier plus every other frontier currently holding slots, including + /// nested VMs) would exceed the per-match active-state ceiling. + pub fn reserve_states(self: &Arc, n: usize) -> Result { + let prev = self.active_states.fetch_add(n, Ordering::Relaxed); + if prev + n > self.budget.limits.max_pattern_states { + self.active_states.fetch_sub(n, Ordering::Relaxed); + return Err(BudgetExceeded::PatternStates { + limit: self.budget.limits.max_pattern_states, + }); + } + Ok(StateReservation { + meter: Arc::clone(self), + held: n, + }) + } +} + +/// An RAII reservation of active-state slots on a [`PatternMeter`]. Holds a +/// count of slots and releases exactly that many when dropped, so every exit +/// path (including `?` early-returns) restores the live-state accounting. +#[must_use] +pub struct StateReservation { + meter: Arc, + held: usize, +} + +impl StateReservation { + /// Grow this reservation by `extra` slots, failing if that would exceed the + /// per-match active-state ceiling (across all live frontiers). Used as a + /// frontier is built incrementally so runaway fan-out fails fast. + pub fn grow(&mut self, extra: usize) -> Result<(), BudgetExceeded> { + let prev = self.meter.active_states.fetch_add(extra, Ordering::Relaxed); + if prev + extra > self.meter.budget.limits.max_pattern_states { + self.meter.active_states.fetch_sub(extra, Ordering::Relaxed); + return Err(BudgetExceeded::PatternStates { + limit: self.meter.budget.limits.max_pattern_states, + }); + } + self.held += extra; + Ok(()) + } + + /// Release `n` previously-reserved slots (e.g. when a generation is fully + /// consumed), keeping the remainder reserved. Saturates at the amount held. + pub fn release(&mut self, n: usize) { + let n = n.min(self.held); + self.held -= n; + self.meter.active_states.fetch_sub(n, Ordering::Relaxed); + } +} + +impl Drop for StateReservation { + fn drop(&mut self) { + self.meter + .active_states + .fetch_sub(self.held, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tiny_limits() -> BudgetLimits { + BudgetLimits { + max_duration: None, + max_operations: Some(10), + max_call_depth: 3, + max_import_depth: 2, + max_execute_file_depth: 2, + max_pattern_steps: 5, + max_pattern_states: 4, + max_source_bytes: 8, + max_request_body_bytes: 8, + max_response_bytes: 8, + max_pending_requests: 2, + max_request_duration: None, + max_ws_queue: 2, + max_ws_connections: 2, + max_ws_message_bytes: 8, + max_ws_queued_bytes: 16, + } + } + + #[test] + fn operation_ceiling_trips_after_limit() { + let budget = ExecutionBudget::new(tiny_limits()); + // Ten operations (indices 0..=9) are allowed; the eleventh (index 10) + // reaches the ceiling. + for _ in 0..10 { + budget.charge_operation(true).expect("within budget"); + } + assert_eq!( + budget.charge_operation(true), + Err(BudgetExceeded::Operations { limit: 10 }) + ); + } + + #[test] + fn main_loop_exemption_skips_operation_ceiling() { + let budget = ExecutionBudget::new(tiny_limits()); + // `enforce_limits = false` (inside a main loop) never trips the ceiling. + for _ in 0..100 { + budget.charge_operation(false).expect("exempt from ceiling"); + } + } + + #[test] + fn cancellation_is_honored_even_when_exempt() { + let budget = ExecutionBudget::new(tiny_limits()); + budget.cancel(); + assert!(budget.is_cancelled()); + // Index 0 is a sample point, so cancellation is observed immediately + // even with `enforce_limits = false`. + assert_eq!( + budget.charge_operation(false), + Err(BudgetExceeded::Cancelled) + ); + } + + #[test] + fn deadline_trips_when_elapsed() { + let mut limits = tiny_limits(); + limits.max_duration = Some(Duration::from_secs(0)); + let budget = ExecutionBudget::new(limits); + // A zero-second deadline is already elapsed at the first sample point. + assert_eq!( + budget.charge_operation(true), + Err(BudgetExceeded::Deadline { limit_secs: 0 }) + ); + } + + #[test] + fn depth_guards_use_ge_semantics() { + let budget = ExecutionBudget::new(tiny_limits()); + // Depths 0,1,2 fit under a limit of 3; depth 3 is refused. + assert!(budget.check_call_depth(2).is_ok()); + assert_eq!( + budget.check_call_depth(3), + Err(BudgetExceeded::CallDepth { limit: 3 }) + ); + assert!(budget.check_import_depth(1).is_ok()); + assert_eq!( + budget.check_import_depth(2), + Err(BudgetExceeded::ImportDepth { limit: 2 }) + ); + assert!(budget.check_execute_file_depth(1).is_ok()); + assert_eq!( + budget.check_execute_file_depth(2), + Err(BudgetExceeded::ExecuteFileDepth { limit: 2 }) + ); + } + + #[test] + fn pattern_checks_use_gt_semantics() { + let budget = ExecutionBudget::new(tiny_limits()); + // Steps equal to the limit are still fine; exceeding it fails. + assert!(budget.check_pattern_steps(5).is_ok()); + assert_eq!( + budget.check_pattern_steps(6), + Err(BudgetExceeded::PatternSteps { limit: 5 }) + ); + assert!(budget.check_pattern_states(4).is_ok()); + assert_eq!( + budget.check_pattern_states(5), + Err(BudgetExceeded::PatternStates { limit: 4 }) + ); + } + + #[test] + fn pattern_meter_is_per_match_and_charges_transitions() { + let budget = Arc::new(ExecutionBudget::new(tiny_limits())); // max_pattern_steps = 5 + let meter = PatternMeter::new(Arc::clone(&budget)); + // Five transitions (indices 0..=4) fit; the sixth (index 5 >= 5) trips. + for _ in 0..5 { + meter.charge_step().expect("within pattern budget"); + } + assert_eq!( + meter.charge_step(), + Err(BudgetExceeded::PatternSteps { limit: 5 }) + ); + // A direct top-level VM op resets *its own* meter for the next match. + meter.reset(); + assert!(meter.charge_step().is_ok()); + + // A second match sharing the same run budget gets an INDEPENDENT meter, + // so it cannot reset or borrow the first meter's transition count. + let other = PatternMeter::new(Arc::clone(&budget)); + for _ in 0..5 { + other.charge_step().expect("independent per-match quota"); + } + assert_eq!( + other.charge_step(), + Err(BudgetExceeded::PatternSteps { limit: 5 }) + ); + } + + #[test] + fn pattern_meter_reserves_states_across_frontiers() { + let budget = Arc::new(ExecutionBudget::new(tiny_limits())); // max_pattern_states = 4 + let meter = PatternMeter::new(Arc::clone(&budget)); + // A "current" frontier of 3 plus a "next" frontier growing to 1 = 4 fits. + let _current = meter.reserve_states(3).expect("current frontier"); + let mut next = meter.reserve_states(0).expect("next frontier"); + next.grow(1).expect("one more still fits (total 4)"); + // A fifth simultaneously-live slot (e.g. a nested lookaround frontier) + // exceeds the ceiling even though no single frontier does. + assert_eq!( + meter.reserve_states(1).map(|_| ()), + Err(BudgetExceeded::PatternStates { limit: 4 }) + ); + // Releasing a frontier frees its slots for reuse. + drop(_current); + let _reused = meter.reserve_states(3).expect("slots freed on drop"); + } + + #[test] + fn pattern_meter_deadline_exemption_is_read_live() { + let mut limits = tiny_limits(); + limits.max_duration = Some(Duration::from_secs(0)); + let budget = Arc::new(ExecutionBudget::new(limits)); + // ONE meter reused across a main-loop boundary (the VM-reuse case); + // `reset()` runs per top-level op, so each op's first charge (index 0) is + // a sample point. + let meter = PatternMeter::new(Arc::clone(&budget)); + // Exempt (inside a `main loop`): a zero-second deadline is not enforced. + let guard = budget.enter_main_loop(); + assert!(budget.is_deadline_exempt()); + meter.reset(); + assert!(meter.charge_step().is_ok()); + // Leaving the main loop: the same meter now enforces the elapsed + // deadline (the exemption is read live, not snapshotted at creation). + drop(guard); + assert!(!budget.is_deadline_exempt()); + meter.reset(); + assert_eq!( + meter.charge_step(), + Err(BudgetExceeded::Deadline { limit_secs: 0 }) + ); + } + + #[test] + fn main_loop_guard_nests_and_restores_on_drop() { + let budget = Arc::new(ExecutionBudget::new(tiny_limits())); + assert!(!budget.is_deadline_exempt()); + let outer = budget.enter_main_loop(); + assert_eq!(budget.main_loop_depth(), 1); + { + let _inner = budget.enter_main_loop(); + assert_eq!(budget.main_loop_depth(), 2); + } + // Inner dropped: still exempt because the outer loop is active. + assert_eq!(budget.main_loop_depth(), 1); + assert!(budget.is_deadline_exempt()); + drop(outer); + assert_eq!(budget.main_loop_depth(), 0); + assert!(!budget.is_deadline_exempt()); + } + + #[test] + fn current_budget_scope_is_restored() { + assert!(ExecutionBudget::current().is_none()); + let outer = Arc::new(ExecutionBudget::new(tiny_limits())); + { + let _g = ExecutionBudget::enter(Arc::clone(&outer)); + assert!(Arc::ptr_eq(&ExecutionBudget::current().unwrap(), &outer)); + } + // Guard dropped: the thread-local is cleared again. + assert!(ExecutionBudget::current().is_none()); + } + + #[test] + fn byte_ceilings_report_actual_and_limit() { + let budget = ExecutionBudget::new(tiny_limits()); + assert!(budget.check_source_bytes(8).is_ok()); + assert_eq!( + budget.check_source_bytes(9), + Err(BudgetExceeded::SourceBytes { + limit: 8, + actual: 9 + }) + ); + assert_eq!( + budget.check_request_body_bytes(100), + Err(BudgetExceeded::RequestBodyBytes { + limit: 8, + actual: 100 + }) + ); + assert_eq!( + budget.check_response_bytes(100), + Err(BudgetExceeded::ResponseBytes { + limit: 8, + actual: 100 + }) + ); + } + + #[test] + fn request_guard_releases_slot_on_drop() { + let budget = Arc::new(ExecutionBudget::new(tiny_limits())); + let a = budget.try_acquire_request().expect("slot 1"); + let b = budget.try_acquire_request().expect("slot 2"); + assert_eq!(budget.pending_requests(), 2); + // At the ceiling of 2, a third acquisition is refused. + assert!(budget.try_acquire_request().is_none()); + drop(a); + assert_eq!(budget.pending_requests(), 1); + // A freed slot can be reacquired. + let _c = budget.try_acquire_request().expect("slot reused"); + drop(b); + } + + #[test] + fn ws_byte_permit_bounds_queued_payload() { + // tiny_limits: max_ws_message_bytes = 8, max_ws_queued_bytes = 16. + let budget = Arc::new(ExecutionBudget::new(tiny_limits())); + // A single frame larger than the per-message cap is refused outright. + assert!(budget.try_reserve_ws_bytes(9).is_none()); + // Two 8-byte frames fill the 16-byte global ceiling. + let a = budget.try_reserve_ws_bytes(8).expect("first frame"); + let b = budget.try_reserve_ws_bytes(8).expect("second frame"); + assert_eq!(budget.ws_queued_bytes(), 16); + // A third frame exceeds the global ceiling and is shed. + assert!(budget.try_reserve_ws_bytes(1).is_none()); + // Consuming a frame frees its bytes for reuse. + drop(a); + assert_eq!(budget.ws_queued_bytes(), 8); + let _c = budget.try_reserve_ws_bytes(8).expect("bytes freed on drop"); + drop(b); + } + + #[test] + fn ws_connection_guard_bounds_live_connections() { + let budget = Arc::new(ExecutionBudget::new(tiny_limits())); + let a = budget.try_acquire_ws_connection().expect("conn 1"); + let _b = budget.try_acquire_ws_connection().expect("conn 2"); + assert_eq!(budget.ws_connections(), 2); + assert!(budget.try_acquire_ws_connection().is_none()); + drop(a); + assert_eq!(budget.ws_connections(), 1); + } + + #[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, + ..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); + } + + #[test] + fn response_timeout_zero_disables() { + let config = WflConfig { + web_server_response_timeout_seconds: 0, + ..Default::default() + }; + assert_eq!( + BudgetLimits::from_config(&config).max_request_duration, + None + ); + let config = WflConfig { + web_server_response_timeout_seconds: 45, + ..Default::default() + }; + assert_eq!( + BudgetLimits::from_config(&config).max_request_duration, + Some(Duration::from_secs(45)) + ); + } + + #[test] + fn budget_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::>(); + } +} diff --git a/src/exec/mod.rs b/src/exec/mod.rs new file mode 100644 index 00000000..bb2edd63 --- /dev/null +++ b/src/exec/mod.rs @@ -0,0 +1,11 @@ +//! Cross-cutting execution primitives shared by the whole runtime. +//! +//! The only inhabitant today is [`budget::ExecutionBudget`], a single object +//! that travels through parsing, evaluation, pattern matching, web handling, +//! and module loading. It consolidates a scatter of previously-isolated caps +//! (the interpreter timeout, the pattern-VM step limit, the web-server body and +//! queue bounds, and so on) behind one coherent, thread-safe mechanism. + +pub mod budget; + +pub use budget::{BudgetExceeded, BudgetLimits, ExecutionBudget}; diff --git a/src/interpreter/error.rs b/src/interpreter/error.rs index baf4533c..0803a32c 100644 --- a/src/interpreter/error.rs +++ b/src/interpreter/error.rs @@ -5,6 +5,9 @@ pub enum ErrorKind { General, EnvDropped, Timeout, + /// A shared `ExecutionBudget` ceiling other than the deadline was reached + /// (operation count, recursion/import/execute-file depth, byte caps, etc.). + ResourceLimit, FileNotFound, PermissionDenied, ProcessNotFound, @@ -47,6 +50,7 @@ impl fmt::Display for RuntimeError { ErrorKind::General => "", ErrorKind::EnvDropped => "[Environment dropped] ", ErrorKind::Timeout => "[Timeout] ", + ErrorKind::ResourceLimit => "[Resource limit] ", ErrorKind::FileNotFound => "[File not found] ", ErrorKind::PermissionDenied => "[Permission denied] ", ErrorKind::ProcessNotFound => "[Process not found] ", diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index f133b9fe..8eb53a69 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -28,6 +28,7 @@ use self::value::{ use crate::builtins::get_function_arity; use crate::config::WflConfig; use crate::debug_report::CallFrame; +use crate::exec::budget::{BudgetExceeded, ExecutionBudget}; #[cfg(debug_assertions)] use crate::exec_block_enter; #[cfg(debug_assertions)] @@ -63,11 +64,33 @@ use tokio::sync::{mpsc, oneshot}; // Type alias for complex pending response type type PendingResponseSender = Arc>>>; + +/// A dequeued HTTP request parked in `pending_responses` awaiting a `respond`. +/// +/// Holds only the response channel. The request's in-flight admission slot is +/// **not** parked here — it lives with the warp transport task, which stays +/// alive awaiting this response, so the slot is released when that task +/// completes (a delivered response, a response timeout, or a client +/// disconnect) independently of any later admitted request. Parking the slot in +/// this map instead pinned it until a *future* dequeued request pruned it, which +/// permanently wedged admission once the cap was full (all route tasks timed out +/// but no new request could be admitted to trigger the prune). +struct PendingResponse { + sender: PendingResponseSender, +} use uuid; use warp::Filter; +/// How often (in charged operations) execution cooperatively yields to the async +/// runtime. A tight CPU-bound `count`/`while`/`repeat` loop otherwise never +/// returns control to the executor, so a `select!` waiting to deliver +/// cooperative cancellation (e.g. the REPL's Ctrl-C → `budget.cancel()`) could +/// not be polled until the run happened to hit real async work. Power of two so +/// `count & (STRIDE - 1)` is exact; large enough that the yield is negligible. +const COOP_YIELD_STRIDE: u64 = 1024; + // Web server data structures -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct WflHttpRequest { pub id: String, pub method: String, @@ -96,6 +119,36 @@ pub struct WflHttpResponse { pub headers: HashMap, } +/// Ensures an HTTP `respond` always resolves its request. The response sender is +/// taken out of `pending_responses` (and out of its mutex) up front and held +/// here; if a fallible step in `respond` returns early before a response is +/// built, `Drop` answers 500 so the client is resolved deterministically instead +/// of hanging until the request timeout. A successful `respond` calls +/// [`ResponseCompletion::take_sender`] to disarm the fallback and deliver the +/// real response. +struct ResponseCompletion { + sender: Option>, +} + +impl ResponseCompletion { + fn take_sender(&mut self) -> Option> { + self.sender.take() + } +} + +impl Drop for ResponseCompletion { + fn drop(&mut self) { + if let Some(sender) = self.sender.take() { + let _ = sender.send(WflHttpResponse { + content: b"Internal Server Error\n".to_vec(), + status: 500, + content_type: "text/plain; charset=utf-8".to_string(), + headers: HashMap::new(), + }); + } + } +} + /// Look up an HTTP header by name in a request headers map. /// /// Header names are case-insensitive (RFC 7230). Warp stores them lowercase, @@ -124,6 +177,18 @@ pub struct WflWebServer { pub server_handle: Option>, } +impl Drop for WflWebServer { + fn drop(&mut self) { + // Safety net for every non-`close server` teardown (interpreter drop, map + // replacement, a caught setup error): abort the accept task so a bound + // listener is never orphaned. `close server` moves `server_handle` out + // first, so this does not double-abort. + if let Some(handle) = self.server_handle.take() { + handle.abort(); + } + } +} + /// Build the 503 response returned when the transport→interpreter request queue /// is full (Phase 0, PR-0c). A free function so the shed path can be tested /// without standing up a live server. @@ -139,6 +204,92 @@ pub fn overloaded_response() -> warp::http::Response> { .expect("static 503 response is always valid") } +/// Build a static `text/plain` response with the given status and message. +fn plain_status_response( + status: warp::http::StatusCode, + message: &str, +) -> warp::http::Response> { + let body = message.as_bytes().to_vec(); + let content_length = body.len(); + warp::http::Response::builder() + .status(status) + .header("Content-Type", "text/plain; charset=utf-8") + .header("Content-Length", content_length) + .body(body) + .expect("static status response is always valid") +} + +/// 413 returned when a request body exceeds `web_server_max_body_size`. Because +/// it is enforced while streaming, a chunked body with no `Content-Length` is +/// bounded too. +fn payload_too_large_response() -> warp::http::Response> { + plain_status_response( + warp::http::StatusCode::PAYLOAD_TOO_LARGE, + "Payload Too Large: request body exceeds the configured limit\n", + ) +} + +/// 504 returned when a handler does not answer an accepted request within +/// `web_server_response_timeout_seconds`, freeing its in-flight slot. +fn gateway_timeout_response() -> warp::http::Response> { + plain_status_response( + warp::http::StatusCode::GATEWAY_TIMEOUT, + "Gateway Timeout: the request handler did not respond in time\n", + ) +} + +/// 408 returned when a client does not finish sending its request body within +/// `web_server_response_timeout_seconds`, so a slow "trickle" upload cannot pin +/// its in-flight slot indefinitely. +fn request_timeout_response() -> warp::http::Response> { + plain_status_response( + warp::http::StatusCode::REQUEST_TIMEOUT, + "Request Timeout: the request body was not received in time\n", + ) +} + +/// Error from [`read_body_capped`]. +enum BodyReadError { + /// The streamed body exceeded the byte ceiling. + TooLarge, + /// The transport failed while reading the body. + Io, +} + +/// Read a streamed request body into a `Vec`, aborting as soon as it exceeds +/// `max` bytes. Unlike buffering the whole body first, this bounds memory for +/// chunked requests (which carry no `Content-Length`): the buffer never grows +/// past `max + 1` bytes before the limit trips. +async fn read_body_capped(stream: S, max: usize) -> Result, BodyReadError> +where + S: futures_util::Stream>, + B: bytes::Buf, +{ + use futures_util::StreamExt; + + futures_util::pin_mut!(stream); + let mut out: Vec = Vec::new(); + while let Some(item) = stream.next().await { + let mut chunk = item.map_err(|_| BodyReadError::Io)?; + while chunk.has_remaining() { + let slice = chunk.chunk(); + // Copy at most enough to reach `max + 1` (one sentinel byte past the + // limit), so a single huge transport chunk cannot grow `out` to + // `max + chunk_len` before the check — the buffer never exceeds + // `max + 1` bytes. + let allowance = max.saturating_sub(out.len()).saturating_add(1); + if slice.len() >= allowance { + out.extend_from_slice(&slice[..allowance]); + return Err(BodyReadError::TooLarge); + } + let take = slice.len(); + out.extend_from_slice(slice); + chunk.advance(take); + } + } + Ok(out) +} + // --------------------------------------------------------------------------- // WebSocket support // @@ -150,10 +301,16 @@ pub fn overloaded_response() -> warp::http::Response> { // `wait for `, so all WFL code still executes on one thread. // --------------------------------------------------------------------------- -/// An outbound frame queued for a single connection's writer task. +/// An outbound frame queued for a single connection's writer task. A `Text` +/// frame carries a [`WsBytePermit`] reserving its payload against the global +/// WebSocket queued-byte budget; the permit releases when the frame is sent +/// (consumed) or shed on a full/closed queue (dropped). #[derive(Debug)] enum WsOutbound { - Text(String), + Text { + text: String, + _permit: crate::exec::budget::WsBytePermit, + }, Close, } @@ -173,11 +330,17 @@ struct WflWsEvent { client_ip: String, /// Text payload for `Message` events; `None` for connect/disconnect. content: Option, + /// For `Message` events, the reservation of this payload's bytes against + /// the global WebSocket queued-byte budget; released when the interpreter + /// consumes (drops) the event. `None` for the payloadless connect/disconnect. + _permit: Option, } /// Outbound-sender registry keyed by connection id, shared with warp tasks. -type WsConnectionRegistry = - Arc>>>; +/// Each sender is a *bounded* channel (sized from the shared budget's +/// `ws_queue_bound`), so a slow client cannot make the server buffer frames +/// without bound — sends `try_send` and shed on `Full`. +type WsConnectionRegistry = Arc>>>; /// A registered `on websocket ...` handler block plus its captured environment. struct WsRegisteredHandler { @@ -197,10 +360,27 @@ struct WsHandlerSet { /// A running WebSocket server: an event stream in, the set of live connection /// ids (for broadcast), the registered handler blocks, and the server task. struct WflWebSocketServer { - event_receiver: Arc>>, + event_receiver: Arc>>, connection_ids: Arc>>, handlers: RefCell, server_handle: Option>, + /// Per-server cancellation: flipping (or dropping) this wakes every live + /// connection's reader `select!` so `close server` terminates each socket + /// task even if the peer ignores the close handshake. + close_tx: tokio::sync::watch::Sender, +} + +impl Drop for WflWebSocketServer { + fn drop(&mut self) { + // Safety net for every non-`close server` teardown (interpreter drop, map + // replacement, a caught setup error): wake connections and abort the + // accept task so a bound listener is never orphaned. `close server` moves + // `server_handle` out first, so this does not double-abort. + let _ = self.close_tx.send(true); + if let Some(handle) = self.server_handle.take() { + handle.abort(); + } + } } /// Drives one upgraded WebSocket connection: registers its outbound channel, @@ -209,19 +389,40 @@ struct WflWebSocketServer { async fn handle_ws_connection( socket: warp::ws::WebSocket, remote_addr: Option, - events: mpsc::UnboundedSender, + events: mpsc::Sender, connections: WsConnectionRegistry, connection_ids: Arc>>, + budget: Arc, + mut cancel: tokio::sync::watch::Receiver, ) { use futures_util::{SinkExt, StreamExt}; - let conn_id = uuid::Uuid::new_v4().to_string(); let client_ip = remote_addr .map(|addr| addr.ip().to_string()) .unwrap_or_else(|| "unknown".to_string()); + // Enforce the shared connection ceiling before doing any per-connection + // work. The guard releases the slot when this task ends (any exit path). + let _conn_guard = match budget.try_acquire_ws_connection() { + Some(guard) => guard, + None => { + log::warn!( + "WebSocket connection limit ({}) reached; refusing connection from {client_ip}", + budget.limits().max_ws_connections + ); + let (mut ws_tx, _ws_rx) = socket.split(); + let _ = ws_tx.send(warp::ws::Message::close()).await; + let _ = ws_tx.flush().await; + return; + } + }; + + let conn_id = uuid::Uuid::new_v4().to_string(); + let (mut ws_tx, mut ws_rx) = socket.split(); - let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + // Bounded outbound queue (sized from the budget): a slow client sheds + // frames on `Full` instead of growing memory without bound. + let (out_tx, mut out_rx) = mpsc::channel::(budget.ws_queue_bound()); if let Ok(mut map) = connections.lock() { map.insert(conn_id.clone(), out_tx); @@ -230,44 +431,111 @@ async fn handle_ws_connection( ids.push(conn_id.clone()); } - let _ = events.send(WflWsEvent { + // The connect event MUST be delivered before this socket is allowed to emit + // messages: it runs the `on websocket connect` handler (app init/auth). If it + // cannot be admitted (queue full or closed), fail closed — unregister the + // connection and close the socket — rather than leaving a live socket whose + // connect handler never ran but which could still emit Message events once + // the queue drains. + if let Err(err) = events.try_send(WflWsEvent { kind: WsEventKind::Connect, connection_id: conn_id.clone(), client_ip: client_ip.clone(), content: None, - }); + _permit: None, + }) { + log::warn!( + "WebSocket connect event for {conn_id} could not be admitted ({err}); closing the connection" + ); + if let Ok(mut map) = connections.lock() { + map.remove(&conn_id); + } + if let Ok(mut ids) = connection_ids.lock() { + ids.retain(|c| c != &conn_id); + } + let _ = ws_tx.send(warp::ws::Message::close()).await; + let _ = ws_tx.flush().await; + return; // `_conn_guard` drops here, releasing the connection slot. + } - // Writer task: drains queued frames to the socket until the channel closes - // or the peer goes away. - let writer = tokio::spawn(async move { + // Writer task: drains queued frames to the socket until an explicit close, + // the peer going away, or the channel closing. + let mut writer = tokio::spawn(async move { + let mut peer_gone = false; while let Some(out) = out_rx.recv().await { match out { - WsOutbound::Text(text) => { + // `_permit` drops at the end of this arm, releasing the frame's + // reserved bytes back to the global queued-byte budget once sent. + WsOutbound::Text { text, _permit } => { if ws_tx.send(warp::ws::Message::text(text)).await.is_err() { + peer_gone = true; break; } } - WsOutbound::Close => { - let _ = ws_tx.send(warp::ws::Message::close()).await; - let _ = ws_tx.flush().await; - break; - } + // Fall through to the unconditional close below. + WsOutbound::Close => break, } } + // Always send a best-effort close frame on exit — whether from an + // explicit `Close`, an empty channel (`close server` dropped every + // sender), or a `Full` queue that could not carry the `Close` message. + // This guarantees `close server` terminates the socket even under + // backpressure, instead of leaving the reader task (and its connection + // slot) alive waiting on the peer. + if !peer_gone { + let _ = ws_tx.send(warp::ws::Message::close()).await; + let _ = ws_tx.flush().await; + } }); // Reader loop: forward inbound text frames as Message events; stop on close. - while let Some(result) = ws_rx.next().await { + // A `select!` on the per-server cancellation receiver means `close server` + // (which flips/drops the watch channel) wakes a reader that would otherwise + // block on `ws_rx.next()` forever waiting on a peer that ignores the close + // handshake — so the task and its connection slot always terminate. + loop { + let result = tokio::select! { + biased; + changed = cancel.changed() => { + // Ok(()) => close requested; Err(_) => the server (its watch + // sender) was dropped. Either way, stop reading. + let _ = changed; + break; + } + next = ws_rx.next() => match next { + Some(r) => r, + None => break, + }, + }; match result { Ok(msg) => { if msg.is_text() { if let Ok(text) = msg.to_str() { - let _ = events.send(WflWsEvent { - kind: WsEventKind::Message, - connection_id: conn_id.clone(), - client_ip: client_ip.clone(), - content: Some(text.to_string()), - }); + // Bound the frame: reject oversized payloads and reserve + // this payload's bytes against the global queued-byte + // budget, so the event queue holds bounded memory, not + // `ws_queue_bound` arbitrarily-large messages. + match budget.try_reserve_ws_bytes(text.len()) { + Some(permit) => { + if let Err(err) = events.try_send(WflWsEvent { + kind: WsEventKind::Message, + connection_id: conn_id.clone(), + client_ip: client_ip.clone(), + content: Some(text.to_string()), + _permit: Some(permit), + }) { + log::warn!( + "WebSocket event queue full; dropping message event for {conn_id}: {err}" + ); + } + } + None => { + log::warn!( + "WebSocket message from {conn_id} ({} bytes) exceeds the per-message or global queued-byte limit; dropping frame", + text.len() + ); + } + } } } else if msg.is_close() { break; @@ -284,13 +552,40 @@ async fn handle_ws_connection( if let Ok(mut ids) = connection_ids.lock() { ids.retain(|c| c != &conn_id); } - let _ = events.send(WflWsEvent { - kind: WsEventKind::Disconnect, - connection_id: conn_id.clone(), - client_ip, - content: None, - }); - writer.abort(); + // The disconnect event MUST be delivered. This connection's connect event + // was delivered (connect is fail-closed above), so its `on websocket + // connect` handler may have initialized per-connection application state, + // and the paired `on websocket disconnect` handler is the only place that + // state is cleaned up. A lossy `try_send` here could drop that cleanup under + // a momentarily-full queue, leaking application state and leaving a queued + // Connect with no matching Disconnect. Use a blocking send so every admitted + // Connect gets its paired Disconnect: it resolves as the interpreter drains + // the queue, and returns `Err` only if the server has shut down (its + // receiver dropped), in which case no disconnect handler remains to run. + if let Err(err) = events + .send(WflWsEvent { + kind: WsEventKind::Disconnect, + connection_id: conn_id.clone(), + client_ip, + content: None, + _permit: None, + }) + .await + { + log::warn!( + "WebSocket disconnect event for {conn_id} could not be delivered (server shut down): {err}" + ); + } + // Bounded close handshake: give the writer a moment to flush its close frame, + // then force teardown so a peer that ignores the handshake cannot keep this + // task alive. The writer exits once its channel drains/closes; on timeout we + // abort it (dropping a JoinHandle would only detach, not stop, the task). + if tokio::time::timeout(Duration::from_millis(250), &mut writer) + .await + .is_err() + { + writer.abort(); + } } impl WsEventKind { @@ -355,14 +650,24 @@ struct Overloaded; impl warp::reject::Reject for Overloaded {} -/// Warp recover handler: turn an [`Overloaded`] rejection into a 503, and -/// re-raise every other rejection so warp's default handling still applies -/// (e.g. oversized-body `ServerError`s are unaffected). +/// Rejection raised when a request's advertised `Content-Length` exceeds the +/// body ceiling, so it is refused before any body is read. Mapped to a 413 by +/// [`handle_overloaded`] (the streaming path returns the 413 directly). +#[derive(Debug)] +struct PayloadTooLarge; + +impl warp::reject::Reject for PayloadTooLarge {} + +/// Warp recover handler: turn an [`Overloaded`] rejection into a 503 and a +/// [`PayloadTooLarge`] rejection into a 413, and re-raise every other rejection +/// so warp's default handling still applies. async fn handle_overloaded( err: warp::Rejection, ) -> Result>, warp::Rejection> { if err.find::().is_some() { Ok(overloaded_response()) + } else if err.find::().is_some() { + Ok(payload_too_large_response()) } else { Err(err) } @@ -425,6 +730,27 @@ fn validate_tls_pem_files(cert_path: &str, key_path: &str) -> Result<(), String> Ok(()) } +/// RAII guard for the interpreter's live recursion depth. Increments on +/// `enter`, decrements on drop (normal return *or* error unwind), so the depth +/// counter always reflects the real call nesting — independent of the +/// diagnostic `call_stack`, which may be force-cleared on a terminal timeout. +struct CallDepthGuard<'a> { + cell: &'a Cell, +} + +impl<'a> CallDepthGuard<'a> { + fn enter(cell: &'a Cell) -> Self { + cell.set(cell.get() + 1); + Self { cell } + } +} + +impl Drop for CallDepthGuard<'_> { + fn drop(&mut self) { + self.cell.set(self.cell.get().saturating_sub(1)); + } +} + /// RAII guard that ensures module loading context is restored on scope exit. /// Automatically pops loading_stack and restores current_source_file when dropped. struct ModuleLoadGuard<'a> { @@ -702,22 +1028,60 @@ use tokio::io::AsyncWriteExt; use tokio::sync::Mutex; // use self::value::FutureValue; -/// Maximum nesting depth for `execute file` runs, guarding against a file -/// that (directly or indirectly) executes itself. Kept small because each -/// nesting level polls through the full interpreter recursively, so deep -/// nesting would exhaust the thread stack (debug builds overflow near a -/// depth of 8) before a larger guard could fire. -const MAX_EXECUTE_FILE_DEPTH: usize = 4; - +/// The WFL tree-walking interpreter. +/// +/// # Stack safety (embedders) +/// +/// The interpreter recurses through several async frames per WFL call, so deep +/// WFL recursion is stack-heavy: an ordinary 8 MiB thread stack overflows near +/// depth ~40 in debug builds. The budget's `max_call_depth` only turns runaway +/// recursion into a clean, catchable `ResourceLimit` error when the stack is +/// large enough to *reach* that limit first. +/// +/// The default public path is therefore **safe by default**: +/// [`Interpreter::new`] — which promises nothing about the thread stack it runs +/// on — caps recursion at the conservative [`Interpreter::DEFAULT_EMBED_CALL_DEPTH`] +/// (not the config-file default of 1000), so a deep WFL program returns a +/// catchable depth error instead of aborting the host process on an ordinary +/// stack. To recurse deeper, opt into both a higher `max_call_depth` (via +/// [`Interpreter::with_config`] / [`Interpreter::with_config_and_budget`]) **and** +/// the large stack from [`crate::run_with_interpreter_stack`] — the combination +/// the WFL CLI uses to reach the full configured 1000. Those config-taking +/// constructors honor the caller's `max_call_depth` verbatim precisely so the +/// CLI (and any embedder that has arranged the stack) can raise it. pub struct Interpreter { global_env: Rc>, current_count: RefCell>, in_count_loop: RefCell, - in_main_loop: RefCell, // Track if we're in a main loop (disables timeout) - op_count: Cell, // Instruction counter for optimized timeout checks - started: Instant, - max_duration: Duration, + // Main-loop state (deadline exemption) now lives on the shared budget as a + // depth counter with an RAII guard (see `ExecutionBudget::enter_main_loop`), + // so it restores on every exit and nests correctly across `execute file`. + /// Monotonic per-statement counter driving the cooperative-yield stride. + /// Increments on every executed statement regardless of the deadline + /// exemption (unlike the operation counter, which a `main loop` skips), so a + /// CPU-bound `main loop` body still yields to the runtime and lets a + /// `select!` deliver cooperative cancellation (the REPL's Ctrl-C). + sched_counter: Cell, + /// The single shared execution budget: deadline/cancellation, operation + /// ceiling, recursion/import/execute-file depth, pattern steps/states, byte + /// caps, pending-request and WebSocket limits. Held behind `Arc` so the + /// multi-threaded web transport (warp accept tasks, per-connection + /// WebSocket tasks) can read it without any `Rc`/`RefCell` crossing a + /// thread boundary. Replaces the old `op_count`/`started`/`max_duration` + /// fields and the scattered per-subsystem constants. + budget: Arc, call_stack: RefCell>, + /// Live recursion depth for enforcement, kept **separate** from `call_stack` + /// (which is diagnostic and gets force-cleared on a terminal timeout). A + /// dedicated RAII counter means a caught `ResourceLimit` can never leave the + /// enforcement depth under-counted, so catch-and-recurse stays bounded. + call_depth: Cell, + /// The depth `call_depth` resets to at the start of a run. Normally 0, but a + /// child interpreter spawned by `execute file` inherits the parent's live + /// depth here, so recursion accounting *spans* the execute-file boundary: a + /// parent already near `max_call_depth` cannot run a child that consumes + /// another full allowance and multiplies the native stack toward overflow. + base_call_depth: usize, #[allow(dead_code)] io_client: Rc, step_mode: bool, // Controls single-step execution mode @@ -725,7 +1089,7 @@ pub struct Interpreter { web_servers: RefCell>, // Web servers by name web_socket_servers: RefCell>, // WebSocket servers keyed by address ws_connections: WsConnectionRegistry, // Outbound senders for all live WebSocket connections - pending_responses: RefCell>, // Pending response senders by request ID + pending_responses: RefCell>, // Pending responses (channel + admission slot) by request ID #[allow(dead_code)] // Used for future security features config: Arc, // Configuration for security and other settings current_source_file: RefCell>, // Currently executing source file (for path resolution) @@ -1750,11 +2114,43 @@ fn lexical_abspath(path: &std::path::Path, cwd: &std::path::Path) -> String { } impl Interpreter { + /// Conservative WFL call/recursion ceiling for an interpreter built via + /// [`Interpreter::new`], the zero-config default path that makes no promise + /// about the thread stack it runs on. WFL's async tree-walker costs enough + /// debug stack per WFL call that an ordinary (e.g. 8 MiB) thread overflows + /// after only a few dozen frames, so this default is kept well below that so + /// the budget's catchable "maximum call depth exceeded" error fires *before* + /// a native stack overflow on such a stack. It is deliberately shallow: + /// programs that recurse deeper must opt into both a higher `max_call_depth` + /// and [`crate::run_with_interpreter_stack`] (see the type-level "Stack + /// safety" docs); the config-taking constructors honor the configured depth, + /// so the CLI reaches the full 1000 on its dedicated 1 GiB stack. + pub const DEFAULT_EMBED_CALL_DEPTH: usize = 12; + pub fn new() -> Self { - Self::with_config(Arc::new(WflConfig::default())) + // The default path makes no stack guarantee, so cap recursion + // conservatively (see `DEFAULT_EMBED_CALL_DEPTH`) rather than inheriting + // the config-file default of 1000, which only stays catchable on the + // CLI's dedicated large stack. + let config = WflConfig { + max_call_depth: Self::DEFAULT_EMBED_CALL_DEPTH, + ..WflConfig::default() + }; + Self::with_config(Arc::new(config)) } pub fn with_config(config: Arc) -> Self { + let budget = Arc::new(ExecutionBudget::from_config(&config)); + Self::with_config_and_budget(config, budget) + } + + /// Construct an interpreter that shares a caller-supplied + /// [`ExecutionBudget`], so one budget can govern a whole run — the CLI's + /// pre-parse source check, lexing/parsing, interpretation, and any nested + /// `execute file` all charge the same deadline, operation ceiling, and + /// cancellation flag. Use [`Interpreter::budget`] to obtain the handle for + /// cancellation. + pub fn with_config_and_budget(config: Arc, budget: Arc) -> Self { let global_env = Environment::new_global(); { @@ -1771,11 +2167,11 @@ impl Interpreter { global_env, current_count: RefCell::new(None), in_count_loop: RefCell::new(false), - in_main_loop: RefCell::new(false), - op_count: Cell::new(0), - started: Instant::now(), - max_duration: Duration::from_secs(config.timeout_seconds), + sched_counter: Cell::new(0), + budget, call_stack: RefCell::new(Vec::new()), + call_depth: Cell::new(0), + base_call_depth: 0, io_client: Rc::new(IoClient::new(Arc::clone(&config))), step_mode: false, // Default to non-step mode script_args: Vec::new(), // Initialize empty, will be set later @@ -1807,6 +2203,27 @@ impl Interpreter { self.step_mode = step_mode; } + /// The shared [`ExecutionBudget`] governing this run. Clone the handle to + /// observe usage or to request cooperative cancellation via + /// [`ExecutionBudget::cancel`] from another task/thread (the budget is + /// `Send + Sync`). + pub fn budget(&self) -> Arc { + Arc::clone(&self.budget) + } + + /// Install a fresh run budget while keeping the rest of the interpreter + /// state (environment, definitions) intact. Used by the REPL to give each + /// command its own wall-clock deadline and cancellation flag without + /// discarding the session's variables. + pub fn set_budget(&mut self, budget: Arc) { + self.budget = budget; + } + + /// A read-only handle to this interpreter's configuration. + pub fn config(&self) -> &Arc { + &self.config + } + pub fn set_script_args(&mut self, args: Vec) { self.script_args = args; } @@ -2146,43 +2563,130 @@ impl Interpreter { &self.global_env } + /// Charge one interpreter operation against the shared budget. This counts + /// the operation, honours cooperative cancellation, and — outside a + /// `main loop` — enforces the operation ceiling and wall-clock deadline. The + /// `main loop` exemption preserves the historic rule that a long-lived + /// server loop never times out; cancellation still applies so a server can + /// be stopped. Clock reads stay throttled to one per 1024 operations inside + /// the budget, matching the previous `op_count & 1023` optimization. fn check_time(&self) -> Result<(), RuntimeError> { - // Skip timeout check if we're in a main loop - if *self.in_main_loop.borrow() { - return Ok(()); + // Deadline/operation-ceiling exemption is driven by the shared budget's + // main-loop depth (see `ExecutionBudget::enter_main_loop`): exempt while + // any `main loop` is active on this run, so a long-lived server never + // times out on its own uptime; cancellation still applies everywhere. + let enforce_limits = !self.budget.is_deadline_exempt(); + match self.budget.charge_operation(enforce_limits) { + Ok(()) => Ok(()), + Err(exceeded) => Err(self.budget_error(exceeded, 0, 0)), } + } - // Optimization: Only check system time every 1024 operations - // This avoids expensive syscalls/hardware clock reads in tight loops - let count = self.op_count.get(); - self.op_count.set(count.wrapping_add(1)); + /// Map a [`BudgetExceeded`] onto a `RuntimeError`. + /// + /// The deadline keeps its historic `[Timeout]` kind (and verbatim message) + /// so existing timeout handling still matches; every other budget breach is + /// a resource-limit error. + /// + /// This maps the breach to a kind but intentionally performs **no** state + /// mutation. Every budget breach — including the deadline — is catchable by + /// a general `try`/`when`, so the call stack, count-loop flags, and recursion + /// depth must unwind *naturally*: `call_function` pops each frame as the + /// error propagates and the RAII `CallDepthGuard` restores `call_depth`. + /// Force-clearing state here would corrupt an enclosing count loop or + /// under-count depth after a catch; `interpret()` resets everything for the + /// next top-level run, so an uncaught terminal breach is fine too. + fn budget_error(&self, exceeded: BudgetExceeded, line: usize, column: usize) -> RuntimeError { + // Do NOT mutate interpreter state here. Every budget breach — deadline, + // operation ceiling, recursion/import/execute-file depth, byte caps — is + // catchable by a general `try`/`when`, so the call stack, count-loop + // flags, and recursion depth must unwind *naturally* to leave a + // consistent, resumable interpreter when the error is caught: + // `call_function` pops each frame and the RAII `CallDepthGuard` restores + // `call_depth` as the error propagates. Force-clearing that state (as the + // historic timeout path did) corrupts an enclosing count loop or + // under-counts depth after a catch. `interpret()` resets everything for + // the next top-level run, so an *uncaught* terminal breach is fine too. + let kind = match exceeded { + BudgetExceeded::Deadline { .. } => ErrorKind::Timeout, + _ => ErrorKind::ResourceLimit, + }; + RuntimeError::with_kind(exceeded.message(), line, column, kind) + } - if count & 1023 != 0 { - return Ok(()); - } + /// Map a pattern-VM error onto a `RuntimeError`. Budget breaches (step/state + /// ceilings, cancellation) surface as catchable `ResourceLimit` errors so a + /// ReDoS/cancellation during matching is not silently collapsed into a + /// non-match; structural pattern errors surface as general runtime errors. + fn pattern_error( + &self, + err: crate::pattern::PatternError, + line: usize, + column: usize, + ) -> RuntimeError { + use crate::pattern::PatternError; + let kind = match err { + // A pattern that outruns the wall-clock deadline is a timeout, with + // the historic `[Timeout]` kind and message, so existing timeout + // handling/tests keep matching. + PatternError::Timeout { .. } => ErrorKind::Timeout, + PatternError::StepLimitExceeded + | PatternError::StateLimitExceeded + | PatternError::Cancelled => ErrorKind::ResourceLimit, + _ => ErrorKind::General, + }; + RuntimeError::with_kind(err.to_string(), line, column, kind) + } - if self.started.elapsed() > self.max_duration { - if *self.in_count_loop.borrow() { - *self.in_count_loop.borrow_mut() = false; - *self.current_count.borrow_mut() = None; - } + /// Read a WFL source file (`load module`, `include from`, `execute file`) + /// under the shared source-size ceiling. Reads at most `max_source_size + 1` + /// bytes, so an oversized file is refused without ever allocating the whole + /// thing — this holds even when the file's metadata is unavailable, stale, + /// or reports `0` (special files), which a metadata-only check would miss. + async fn read_source_bounded( + &self, + path: &std::path::Path, + line: usize, + column: usize, + ) -> Result { + use tokio::io::AsyncReadExt; - // Force all resources to be released - self.call_stack.borrow_mut().clear(); + let io_err = |e: std::io::Error| { + let kind = match e.kind() { + std::io::ErrorKind::NotFound => ErrorKind::FileNotFound, + std::io::ErrorKind::PermissionDenied => ErrorKind::PermissionDenied, + _ => ErrorKind::General, + }; + RuntimeError::with_kind( + format!("Cannot read source file '{}': {e}", path.display()), + line, + column, + kind, + ) + }; - // Terminate with a timeout error - Err(RuntimeError::with_kind( - format!( - "Execution exceeded timeout ({}s)", - self.max_duration.as_secs() - ), - 0, - 0, - ErrorKind::Timeout, - )) - } else { - Ok(()) + let max = self.budget.max_source_bytes(); + // Read one byte past the limit so exceeding it is detectable; the buffer + // never grows beyond `max + 1`. + let read_cap = (max as u64).saturating_add(1); + let file = tokio::fs::File::open(path).await.map_err(io_err)?; + let mut buf = Vec::new(); + file.take(read_cap) + .read_to_end(&mut buf) + .await + .map_err(io_err)?; + + if let Err(exceeded) = self.budget.check_source_bytes(buf.len()) { + return Err(self.budget_error(exceeded, line, column)); } + + String::from_utf8(buf).map_err(|_| { + RuntimeError::new( + format!("Source file '{}' is not valid UTF-8", path.display()), + line, + column, + ) + }) } fn assert_invariants(&self) { @@ -2207,6 +2711,36 @@ impl Interpreter { } pub async fn interpret(&mut self, program: &Program) -> Result> { + // Scope this run's budget as the TASK-local current budget, so leaf + // helpers with no budget parameter (the stdlib pattern builtins in + // particular) match under the run's configured ceilings and shared + // meters — and, crucially, so a library embedder that interleaves two + // interpreter futures on one thread never sees the other's budget or + // restores stale state across an `.await`. An `execute file` child that + // calls `interpret` again nests its own scope (same or child budget). + ExecutionBudget::scope(Arc::clone(&self.budget), self.interpret_inner(program)).await + } + + /// The interpreter run body, executed inside the task-local budget scope + /// established by [`Interpreter::interpret`]. + async fn interpret_inner(&mut self, program: &Program) -> Result> { + // Reset per-run enforcement/loop state first, so a prior *terminal* + // budget breach (e.g. an uncaught timeout that unwound to the top) can't + // leak stale count-loop or depth state into this run — matters when one + // interpreter is reused (the REPL). Done before `assert_invariants` so + // the invariant holds regardless of how the previous run ended. + *self.in_count_loop.borrow_mut() = false; + *self.current_count.borrow_mut() = None; + // Reset to the inherited base depth (0 for a top-level run/REPL; the + // parent's live depth for an `execute file` child) so recursion + // accounting spans the execute-file boundary instead of granting the + // child a fresh full allowance. + self.call_depth.set(self.base_call_depth); + // NOTE: do NOT touch the shared budget's main-loop depth here. It is + // managed entirely by the RAII `MainLoopGuard`, so it never leaks (a + // mid-loop unwind drops the guard); and for an `execute file` child that + // shares the parent's budget, clearing it would wrongly cancel the + // parent's still-active main-loop exemption. self.assert_invariants(); self.call_stack.borrow_mut().clear(); @@ -2457,6 +2991,19 @@ impl Interpreter { ) -> Result<(Value, ControlFlow), RuntimeError> { self.check_time()?; + // Cooperatively yield to the async runtime on a throttled stride so a + // tight CPU-bound loop periodically returns control to the executor, + // letting a `select!` deliver cooperative cancellation (e.g. the REPL's + // Ctrl-C → `budget.cancel()`). Driven by a dedicated per-statement + // counter that advances even inside a `main loop` (whose operation + // counter is exempt and whose body is not guaranteed to await anything + // that returns `Pending`), so a CPU-only main loop still yields. + let sched = self.sched_counter.get().wrapping_add(1); + self.sched_counter.set(sched); + if sched & (COOP_YIELD_STRIDE - 1) == 0 { + tokio::task::yield_now().await; + } + let env_before = if self.step_mode { self.global_env.borrow().values.clone() } else { @@ -3143,14 +3690,19 @@ impl Interpreter { #[cfg(debug_assertions)] exec_trace!("Executing main loop (timeout disabled)"); - // Set the main loop flag to disable timeout - *self.in_main_loop.borrow_mut() = true; + // Enter the main loop's deadline exemption via an RAII guard on + // the shared budget. The guard restores the depth on EVERY exit — + // a normal end, an early `return` below, a caught error unwinding + // through `?`, or a nested main loop — so the exemption is never + // leaked or cleared while an outer loop is still active. A child + // `execute file` sharing this budget inherits the exemption too. + let _main_loop_guard = self.budget.enter_main_loop(); let mut _last_value = Value::Null; let mut loop_env_recycle = None; loop { - // Note: check_time() will skip timeout check when in_main_loop is true + // check_time() skips the deadline while the main-loop depth > 0 self.check_time()?; // OPTIMIZATION: Recycle environment if possible @@ -3175,22 +3727,19 @@ impl Interpreter { ControlFlow::Exit => { #[cfg(debug_assertions)] exec_trace!("Exiting from main loop"); - *self.in_main_loop.borrow_mut() = false; + // `_main_loop_guard` drops here, restoring the depth. return Ok((_last_value, ControlFlow::Exit)); } ControlFlow::Return(val) => { #[cfg(debug_assertions)] exec_trace!("Returning from main loop with value: {:?}", val); - *self.in_main_loop.borrow_mut() = false; return Ok((val.clone(), ControlFlow::Return(val))); } ControlFlow::None => {} } } - // Reset the main loop flag when exiting normally - *self.in_main_loop.borrow_mut() = false; - + // `_main_loop_guard` drops here on normal exit. Ok((_last_value, ControlFlow::None)) } @@ -3710,25 +4259,31 @@ impl Interpreter { // 2. Resolve absolute path let resolved_path = self.resolve_module_path(&path_str, *line, *column).await?; - // 3. Check circular dependencies + // 3. Check circular dependencies and the shared import-depth + // ceiling (loading_stack length is the depth already entered). self.check_circular_dependency(&resolved_path, *line, *column)?; + if let Err(exceeded) = self + .budget + .check_import_depth(self.loading_stack.borrow().len()) + { + return Err(self.budget_error(exceeded, *line, *column)); + } - // 4. Read file content - let content = tokio::fs::read_to_string(&resolved_path) - .await - .map_err(|e| { - RuntimeError::new( - format!("Cannot load module '{}': {}", path_str, e), - *line, - *column, - ) - })?; + // 4. Read file content under the shared source-size ceiling. + let content = self + .read_source_bounded(&resolved_path, *line, *column) + .await?; // 6. Parse module - use crate::lexer::lex_wfl_with_positions; + use crate::lexer::lex_wfl_with_positions_checked; use crate::parser::Parser; - let tokens = lex_wfl_with_positions(&content); + // Lex under the shared run budget: a deadline / cancellation / + // operation breach during nested source loading surfaces as a + // typed, catchable runtime error instead of a truncated token + // stream that could execute as if it were the whole file. + let tokens = lex_wfl_with_positions_checked(&content) + .map_err(|exceeded| self.budget_error(exceeded, *line, *column))?; let mut parser = Parser::new(&tokens); let program = parser.parse().map_err(|errors| { // Use the parse error's position from the module file, not the load site @@ -3769,24 +4324,34 @@ impl Interpreter { } // 8. Type check - use crate::typechecker::TypeChecker; + use crate::typechecker::{TypeCheckError, TypeChecker}; // Use the analyzer with parent scope for type checking let mut tc = TypeChecker::with_analyzer(analyzer); - if let Err(type_errors) = tc.check_types(&program) { - // Use the type error's position from the module file, not the load site - let first_error = type_errors.first(); - let (error_line, error_column) = - first_error.map(|e| (e.line, e.column)).unwrap_or((1, 1)); - return Err(RuntimeError::new( - format!( - "Type error in module '{}': {}", - resolved_path.display(), - first_error.map(|e| e.to_string()).unwrap_or_default() - ), - error_line, - error_column, - )); + if let Err(failure) = tc.check_types(&program) { + match failure { + // A shared-budget breach while type-checking the module is + // fatal: surface it as the catchable resource/timeout + // error rather than a "type error in module". + TypeCheckError::Budget(exceeded) => { + return Err(self.budget_error(exceeded, 0, 0)); + } + TypeCheckError::Types(type_errors) => { + // Use the type error's position from the module file, not the load site + let first_error = type_errors.first(); + let (error_line, error_column) = + first_error.map(|e| (e.line, e.column)).unwrap_or((1, 1)); + return Err(RuntimeError::new( + format!( + "Type error in module '{}': {}", + resolved_path.display(), + first_error.map(|e| e.to_string()).unwrap_or_default() + ), + error_line, + error_column, + )); + } + } } // 8. Create isolated child environment @@ -3870,25 +4435,31 @@ impl Interpreter { // 2. Resolve absolute path let resolved_path = self.resolve_module_path(&path_str, *line, *column).await?; - // 3. Check circular dependencies + // 3. Check circular dependencies and the shared import-depth + // ceiling (loading_stack length is the depth already entered). self.check_circular_dependency(&resolved_path, *line, *column)?; + if let Err(exceeded) = self + .budget + .check_import_depth(self.loading_stack.borrow().len()) + { + return Err(self.budget_error(exceeded, *line, *column)); + } - // 4. Read file content - let content = tokio::fs::read_to_string(&resolved_path) - .await - .map_err(|e| { - RuntimeError::new( - format!("Cannot include file '{}': {}", path_str, e), - *line, - *column, - ) - })?; + // 4. Read file content under the shared source-size ceiling. + let content = self + .read_source_bounded(&resolved_path, *line, *column) + .await?; // 5. Parse included file - use crate::lexer::lex_wfl_with_positions; + use crate::lexer::lex_wfl_with_positions_checked; use crate::parser::Parser; - let tokens = lex_wfl_with_positions(&content); + // Lex under the shared run budget: a deadline / cancellation / + // operation breach during nested source loading surfaces as a + // typed, catchable runtime error instead of a truncated token + // stream that could execute as if it were the whole file. + let tokens = lex_wfl_with_positions_checked(&content) + .map_err(|exceeded| self.budget_error(exceeded, *line, *column))?; let mut parser = Parser::new(&tokens); let program = parser.parse().map_err(|errors| { let first_error = errors.first(); @@ -3932,21 +4503,34 @@ impl Interpreter { // strictly than the same code written in the main program // (issues #551/#553). use crate::diagnostics::DiagnosticReporter; - use crate::typechecker::TypeChecker; + use crate::typechecker::{TypeCheckError, TypeChecker}; let mut tc = TypeChecker::with_analyzer(analyzer); - if let Err(type_errors) = tc.check_types(&program) { - eprintln!( - "Type checking warnings in included file '{}':", - resolved_path.display() - ); - let mut reporter = DiagnosticReporter::new(); - let file_id = - reporter.add_file(resolved_path.display().to_string(), content.clone()); - for error in &type_errors { - let diagnostic = reporter.convert_type_error(file_id, error); - if reporter.report_diagnostic(file_id, &diagnostic).is_err() { - eprintln!("{error}"); + if let Err(failure) = tc.check_types(&program) { + match failure { + // Ordinary type diagnostics stay non-fatal warnings here + // (included code must never be checked more strictly than + // the same code in the main file). A shared-budget breach + // is the exception: the deadline/cancellation/resource + // limit was hit while checking the included file, so the + // run must stop instead of executing it. + TypeCheckError::Budget(exceeded) => { + return Err(self.budget_error(exceeded, 0, 0)); + } + TypeCheckError::Types(type_errors) => { + eprintln!( + "Type checking warnings in included file '{}':", + resolved_path.display() + ); + let mut reporter = DiagnosticReporter::new(); + let file_id = reporter + .add_file(resolved_path.display().to_string(), content.clone()); + for error in &type_errors { + let diagnostic = reporter.convert_type_error(file_id, error); + if reporter.report_diagnostic(file_id, &diagnostic).is_err() { + eprintln!("{error}"); + } + } } } } @@ -5414,67 +5998,61 @@ impl Interpreter { // Create request/response channels. The request queue is bounded // (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(); let (request_sender, request_receiver) = mpsc::channel::(queue_bound); let request_receiver = Arc::new(tokio::sync::Mutex::new(request_receiver)); - // Cap concurrently in-flight requests so a flood cannot allocate - // an unbounded number of request bodies before the queue check - // (Phase 0, PR-0c). A permit is acquired *before* `body::bytes()` - // buffers the payload and released when the request completes; - // when none is available the request is shed with 503 before any - // body is read. - let inflight = Arc::new(tokio::sync::Semaphore::new(queue_bound)); - // Create warp routes that handle all HTTP methods and paths. - // Body size: reject oversized Content-Length *before* buffering - // (when the header is present), then re-check after - // `body::bytes()` for missing/wrong Content-Length. GET and - // other no-body requests still work without Content-Length. - // Limit is configurable via `.wflcfg` `web_server_max_body_size` - // (default 1 MB). + // In-flight admission uses the shared ExecutionBudget's global + // request cap (RequestGuard), so a flood is bounded across every + // listener — not per-server. The guard is acquired *before* the + // body is read and held until the handler answers, the request + // times out, or the client disconnects, so a dequeued request + // can no longer pin memory indefinitely. Body size is enforced + // *while streaming* (below), which bounds chunked bodies that + // carry no Content-Length. 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(); let max_body_size_u64 = max_body_size as u64; + let request_timeout = self.budget.max_request_duration(); + let admit_budget = Arc::clone(&self.budget); let routes = warp::any() .and(warp::method()) .and(warp::path::full()) .and(warp::query::raw().or(warp::any().map(String::new)).unify()) .and(warp::header::headers_cloned()) - // Early reject when the client advertises an oversized body, - // so we never allocate for that payload. Optional header so - // GETs without Content-Length are unaffected. + // Fast path: reject when the client advertises an oversized + // body, before we admit it or read a byte. Optional header so + // GETs (and chunked bodies) without Content-Length are still + // admitted and then bounded by the streaming check below. .and( warp::header::optional::("content-length").and_then( move |len: Option| async move { if let Some(len) = len && len > max_body_size_u64 { - return Err(warp::reject::custom(ServerError(format!( - "Request body too large: {len} bytes (limit: {max_body_size_u64} bytes)" - )))); + return Err(warp::reject::custom(PayloadTooLarge)); } Ok::<(), warp::Rejection>(()) }, ), ) - // Admission control: acquire an in-flight permit *before* the - // body is buffered. If the server is saturated, shed with a - // 503 (via the `Overloaded` rejection + `handle_overloaded`) - // so we never allocate a body for a request we can't serve. + // Admission control: reserve a global in-flight slot *before* + // the body is read. At the ceiling, shed with 503 (via the + // `Overloaded` rejection) without reading a body. .and({ - let inflight = inflight.clone(); + let admit_budget = Arc::clone(&admit_budget); warp::any().and_then(move || { - let inflight = inflight.clone(); + let admit_budget = Arc::clone(&admit_budget); async move { - inflight - .try_acquire_owned() - .map_err(|_| warp::reject::custom(Overloaded)) + admit_budget + .try_acquire_request() + .ok_or_else(|| warp::reject::custom(Overloaded)) } }) }) - .and(warp::body::bytes()) + .and(warp::body::stream()) .and(warp::addr::remote()) .and_then( move |method: warp::http::Method, @@ -5482,29 +6060,70 @@ impl Interpreter { query: String, headers: warp::http::HeaderMap, (), - permit: tokio::sync::OwnedSemaphorePermit, - body: bytes::Bytes, + guard: crate::exec::budget::RequestGuard, + body_stream, remote_addr: Option| { let sender = request_sender_clone.clone(); async move { - // `permit` (acquired before the body was buffered) - // is released right after the request is enqueued - // below — see the `drop(permit)` in the `try_send` - // Ok arm. Holding it across the untimed response - // wait would let a handler that never calls - // `respond` pin the in-flight cap and take the - // server offline; an actual per-request response - // timeout is Phase 1 work. On the early returns - // below, `permit` is dropped when the future ends. - // Safety net when Content-Length was absent or lied: - // still refuse after buffering so the limit holds. - if body.len() > max_body_size { - return Err(warp::reject::custom(ServerError(format!( - "Request body too large: {} bytes (limit: {} bytes)", - body.len(), - max_body_size - )))); - } + // Hold the admission slot for this request's WHOLE + // transport lifetime by binding the guard into this + // future: it drops when the future completes — on a + // delivered response, a response/body timeout, or a + // client disconnect (warp cancels the future) — + // releasing the in-flight slot INDEPENDENTLY of any + // later admitted request. This future stays alive + // awaiting the response (below) even after the + // interpreter dequeues the request, so the slot is + // still held during handling — it is not released at + // dequeue. Parking the guard in the interpreter's + // pending map instead pinned it until a *future* + // dequeued request pruned it, which could never + // happen once the cap was full — permanently wedging + // admission. The bounded request mpsc separately + // caps still-queued bodies, so releasing here does + // not let queued-body memory grow unbounded. + let _admission_guard = guard; + + // One deadline for the whole accepted-request + // lifetime (body read + handler response), set at + // admission. Applying it to the *body read* is + // what stops a slow "trickle" upload (a chunked + // body dribbled under the size cap forever) from + // pinning its global in-flight slot: without this, + // only the response wait was bounded. + let overall_deadline = request_timeout + .map(|dur| tokio::time::Instant::now() + dur); + + // Enforce the body limit while streaming so a + // chunked body (no Content-Length) is bounded too, + // and bound the read by the shared deadline. + let read_fut = read_body_capped(body_stream, max_body_size); + let body_read = match overall_deadline { + Some(dl) => match tokio::time::timeout_at(dl, read_fut).await { + Ok(r) => r, + Err(_) => { + log::warn!( + "web server request from {} did not finish its body in time; shedding 408", + remote_addr + .map(|a| a.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()), + ); + return Ok(request_timeout_response()); + } + }, + None => read_fut.await, + }; + let body_bytes = match body_read { + Ok(bytes) => bytes, + Err(BodyReadError::TooLarge) => { + return Ok(payload_too_large_response()); + } + Err(BodyReadError::Io) => { + return Err(warp::reject::custom(ServerError( + "Failed to read request body".to_string(), + ))); + } + }; // Generate unique request ID let request_id = uuid::Uuid::new_v4().to_string(); @@ -5522,16 +6141,16 @@ impl Interpreter { } } - // Keep the raw body bytes so binary uploads survive; - // WFL exposes both a lossy-text `body` and a lossless - // `body_bytes` view of these bytes. - let body_bytes = body.to_vec(); - // Create response channel let (response_sender, response_receiver) = oneshot::channel::(); - // Create WFL request + // Create the WFL request. The admission guard is + // NOT moved in — it stays bound to this transport + // future (see `_admission_guard` above), which + // outlives the enqueue and awaits the response, so + // the slot is held through handling and released + // when this future ends (respond/timeout/disconnect). let wfl_request = WflHttpRequest { id: request_id, method: method.to_string(), @@ -5546,21 +6165,12 @@ impl Interpreter { }; // Send request to WFL interpreter. The queue is - // bounded (Phase 0, PR-0c): a full queue means the - // interpreter is saturated, so shed with 503 rather - // than buffering unbounded work. `try_send` never + // bounded: a full queue means the interpreter is + // saturated, so shed with 503 rather than + // buffering unbounded work. `try_send` never // blocks the transport task. match sender.try_send(wfl_request) { - Ok(()) => { - // Enqueued: the body is now owned by the - // bounded queue (then the serial - // interpreter), so release the admission - // permit before the response wait. Concurrent - // body buffering stays bounded without pinning - // a permit to a possibly-never-answered - // request. - drop(permit); - } + Ok(()) => {} Err(mpsc::error::TrySendError::Full(shed)) => { log::warn!( "web server request queue full (capacity {}); shedding {} {} from {} with 503", @@ -5578,8 +6188,32 @@ impl Interpreter { } } - // Wait for response - match response_receiver.await { + // Wait for the handler's response, bounded by the + // *same* deadline as the body read so a handler + // that never answers frees its in-flight slot with + // a 504. Dropping `response_receiver` here closes + // its oneshot sender, which the interpreter + // observes (`is_closed`) to skip/prune the + // abandoned request rather than run zombie work. + let received = match overall_deadline { + Some(dl) => { + match tokio::time::timeout_at(dl, response_receiver).await { + Ok(r) => r, + Err(_) => { + log::warn!( + "web server request from {} timed out awaiting handler; shedding 504", + remote_addr + .map(|a| a.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()), + ); + return Ok(gateway_timeout_response()); + } + } + } + None => response_receiver.await, + }; + + match received { Ok(response) => { let status_code = warp::http::StatusCode::from_u16(response.status) @@ -5983,40 +6617,63 @@ impl Interpreter { None }; - // Wait for request with or without timeout - if let Some(duration) = timeout_duration { - match tokio::time::timeout(duration, receiver.recv()).await { - Ok(Some(req)) => req, - Ok(None) => { - return Err(RuntimeError::new( - "Request channel closed".to_string(), - *line, - *column, - )); - } - Err(_) => { - return Err(RuntimeError::new( - format!( - "Timeout waiting for request ({} ms)", - duration.as_millis() - ), - *line, - *column, - )); + // Wait for request with or without timeout. Loop so a request + // whose client already gave up (its oneshot receiver dropped + // on 408/504/disconnect, closing the sender) is skipped rather + // than handled — otherwise the interpreter would run a handler + // for a dead request and register a dead pending-response + // entry, letting repeated timeouts accumulate zombie work. + loop { + let req = if let Some(duration) = timeout_duration { + match tokio::time::timeout(duration, receiver.recv()).await { + Ok(Some(req)) => req, + Ok(None) => { + return Err(RuntimeError::new( + "Request channel closed".to_string(), + *line, + *column, + )); + } + Err(_) => { + return Err(RuntimeError::new( + format!( + "Timeout waiting for request ({} ms)", + duration.as_millis() + ), + *line, + *column, + )); + } } - } - } else { - // No timeout - wait indefinitely - match receiver.recv().await { - Some(req) => req, - None => { - return Err(RuntimeError::new( - "Request channel closed".to_string(), - *line, - *column, - )); + } else { + // No timeout - wait indefinitely + match receiver.recv().await { + Some(req) => req, + None => { + return Err(RuntimeError::new( + "Request channel closed".to_string(), + *line, + *column, + )); + } } + }; + + let abandoned = { + let sender_opt = req.response_sender.lock().await; + sender_opt.as_ref().is_none_or(|s| s.is_closed()) + }; + if abandoned { + log::debug!( + "skipping abandoned request {} ({} {}) from {}", + req.id, + req.method, + req.path, + req.client_ip + ); + continue; } + break req; } }; @@ -6095,7 +6752,23 @@ impl Interpreter { // client hanging instead of failing fast. { let mut pending_responses = self.pending_responses.borrow_mut(); - pending_responses.insert(request.id.clone(), request.response_sender); + // Prune entries whose client already disconnected/timed out + // (oneshot sender closed) before inserting the new one, so a + // handler that never `respond`s to a since-abandoned request + // cannot let the map grow without bound across many timeouts. + // (The admission slot itself is released by the transport task, + // not this prune — see `PendingResponse`.) + pending_responses.retain(|_, pending| match pending.sender.try_lock() { + Ok(guard) => guard.as_ref().is_some_and(|s| !s.is_closed()), + // Locked right now (being responded to) — keep it. + Err(_) => true, + }); + pending_responses.insert( + request.id.clone(), + PendingResponse { + sender: request.response_sender, + }, + ); } Ok((Value::Null, ControlFlow::None)) @@ -6134,19 +6807,92 @@ impl Interpreter { } }; + // Take the response sender out of the pending map (and out of its + // mutex) up front, into an RAII completion guard, *before* any + // fallible response construction below (content/status/type/header + // evaluation, byte-cap checks). On an early error the guard's Drop + // answers 500, so the request is always resolved instead of + // hanging until its timeout; a successful respond disarms it via + // `take_sender`. + let pending_entry = { + let mut pending = self.pending_responses.borrow_mut(); + pending.remove(&request_id) + }; + let mut completion = match pending_entry { + // The admission slot is released by the transport task when it + // finishes delivering this response (or on its timeout), so the + // completion guard carries only the response channel. + Some(entry) => match entry.sender.lock().await.take() { + Some(sender) => ResponseCompletion { + sender: Some(sender), + }, + None => { + return Err(RuntimeError::new( + "Response already sent for this request".to_string(), + *line, + *column, + )); + } + }, + None => { + return Err(RuntimeError::new( + "Request ID not found - response may have already been sent" + .to_string(), + *line, + *column, + )); + } + }; + // Evaluate response content. Binary values are carried through // as raw bytes so fonts/images/etc. serve losslessly; text and // scalar values keep their existing UTF-8 rendering. let content_val = self.evaluate_expression(content, Rc::clone(&env)).await?; let is_binary = matches!(content_val, Value::Binary(_)); + + // Enforce the response-body ceiling on the *borrowed* length + // first, so an oversized Text/Binary body is refused before it is + // duplicated into `content_bytes` (bounding peak allocation). + if let Value::Text(text) = &content_val + && let Err(exceeded) = self.budget.check_response_bytes(text.len()) + { + return Err(self.budget_error(exceeded, *line, *column)); + } + if let Value::Binary(bytes) = &content_val + && let Err(exceeded) = self.budget.check_response_bytes(bytes.len()) + { + return Err(self.budget_error(exceeded, *line, *column)); + } + let content_bytes: Vec = match &content_val { Value::Text(text) => text.as_bytes().to_vec(), Value::Number(n) => n.to_string().into_bytes(), Value::Bool(b) => b.to_string().into_bytes(), Value::Binary(bytes) => bytes.to_vec(), - _ => format!("{content_val:?}").into_bytes(), + Value::Null => Vec::new(), + // Composite/opaque values (lists, objects, functions, …) have + // no meaningful HTTP body rendering, and their `{:?}` form is + // unbounded — materializing it would allocate past the + // response cap before it could be checked. Reject them with a + // clear error instead. + other => { + return Err(RuntimeError::new( + format!( + "Cannot use {} as a response body; respond with text, a number, a boolean, binary data, or nothing", + other.type_name() + ), + *line, + *column, + )); + } }; + // Re-check the materialized length to cover the small formatted + // variants (Number/Bool), which have no cheap borrowed length. + if let Err(exceeded) = self.budget.check_response_bytes(content_bytes.len()) { + return Err(self.budget_error(exceeded, *line, *column)); + } + // Evaluate status code (optional) let status_code = if let Some(status_expr) = status { let status_val = self @@ -6252,15 +6998,10 @@ impl Interpreter { headers: custom_headers, }; - // Send response - let response_sender = { - let mut pending = self.pending_responses.borrow_mut(); - pending.remove(&request_id) - }; - - if let Some(sender_arc) = response_sender { - let mut sender_opt = sender_arc.lock().await; - if let Some(sender) = sender_opt.take() { + // Deliver the response and disarm the guard's 500 fallback. The + // sender was taken up front, so this is the sole delivery path. + match completion.take_sender() { + Some(sender) => { if sender.send(response).is_err() { return Err(RuntimeError::new( "Failed to send response - client may have disconnected" @@ -6269,19 +7010,14 @@ impl Interpreter { *column, )); } - } else { + } + None => { return Err(RuntimeError::new( "Response already sent for this request".to_string(), *line, *column, )); } - } else { - return Err(RuntimeError::new( - "Request ID not found - response may have already been sent".to_string(), - *line, - *column, - )); } Ok((Value::Null, ControlFlow::None)) @@ -6368,7 +7104,11 @@ impl Interpreter { && name.starts_with("WebSocketServer::") { let key = name.to_string(); - if let Some(ws_server) = self.web_socket_servers.borrow_mut().remove(&key) { + if let Some(mut ws_server) = self.web_socket_servers.borrow_mut().remove(&key) { + // Wake every live connection's reader so it stops waiting + // on the peer and tears down (releasing its slot), even if + // the peer never answers the close handshake. + let _ = ws_server.close_tx.send(true); let ids = ws_server .connection_ids .lock() @@ -6377,11 +7117,11 @@ impl Interpreter { if let Ok(mut map) = self.ws_connections.lock() { for id in ids { if let Some(tx) = map.remove(&id) { - let _ = tx.send(WsOutbound::Close); + let _ = tx.try_send(WsOutbound::Close); } } } - if let Some(handle) = ws_server.server_handle { + if let Some(handle) = ws_server.server_handle.take() { // Give queued close frames a moment to flush before // the accept task is torn down. tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; @@ -6443,10 +7183,10 @@ impl Interpreter { // Close the server let mut web_servers = self.web_servers.borrow_mut(); - if let Some(wfl_server) = web_servers.remove(&server_name) { + if let Some(mut wfl_server) = web_servers.remove(&server_name) { // Graceful shutdown: Give in-flight responses time to complete transmission // before forcefully aborting the server task - if let Some(handle) = wfl_server.server_handle { + if let Some(handle) = wfl_server.server_handle.take() { // Allow 50ms for pending HTTP responses to be transmitted // This prevents race condition where abort() closes the TCP connection // before response bytes reach the client, causing IncompleteMessage errors @@ -6483,23 +7223,50 @@ impl Interpreter { } }; - let (event_sender, event_receiver) = mpsc::unbounded_channel::(); + // Bounded lifecycle-event channel (sized from the shared budget): + // a flood of connect/message/disconnect events sheds on `Full` + // rather than growing memory without bound. + let (event_sender, event_receiver) = + mpsc::channel::(self.budget.ws_queue_bound()); let event_receiver = Arc::new(tokio::sync::Mutex::new(event_receiver)); let connection_ids = Arc::new(std::sync::Mutex::new(Vec::new())); + // Per-server cancellation channel. Each connection clones the + // receiver; `close server` flips/drops the sender to wake them. + let (close_tx, close_rx) = tokio::sync::watch::channel(false); // Clones handed to warp's per-connection tasks. let ws_connections = Arc::clone(&self.ws_connections); let connection_ids_task = Arc::clone(&connection_ids); let event_sender_task = event_sender.clone(); - + let budget_task = Arc::clone(&self.budget); + let close_rx_task = close_rx.clone(); + + // Cap the transport's own message/frame assembly at the budget's + // per-message limit *before* upgrade, so a fragmented text frame + // or an ignored binary frame cannot allocate up to Tungstenite's + // independent defaults on the receive side. The queued-byte permit + // in the reader loop is then the second (global) layer. + let max_ws_message = self.budget.max_ws_message_bytes(); let route = warp::ws().and(warp::addr::remote()).map( move |ws: warp::ws::Ws, remote: Option| { let events = event_sender_task.clone(); let connections = Arc::clone(&ws_connections); let ids = Arc::clone(&connection_ids_task); - ws.on_upgrade(move |socket| { - handle_ws_connection(socket, remote, events, connections, ids) - }) + let budget = Arc::clone(&budget_task); + let cancel = close_rx_task.clone(); + ws.max_message_size(max_ws_message) + .max_frame_size(max_ws_message) + .on_upgrade(move |socket| { + handle_ws_connection( + socket, + remote, + events, + connections, + ids, + budget, + cancel, + ) + }) }, ); @@ -6527,6 +7294,7 @@ impl Interpreter { connection_ids, handlers: RefCell::new(WsHandlerSet::default()), server_handle: Some(server_handle), + close_tx, }; self.web_socket_servers .borrow_mut() @@ -6592,7 +7360,8 @@ impl Interpreter { column, } => { let message_val = self.evaluate_expression(message, Rc::clone(&env)).await?; - let text = Self::ws_message_text(&message_val, *line, *column)?; + // Measure the payload from the borrowed value first (no clone). + let msg_len = Self::ws_message_byte_len(&message_val, *line, *column)?; let target_val = self.evaluate_expression(target, Rc::clone(&env)).await?; let conn_id = Self::ws_connection_id(&target_val, *line, *column)?; @@ -6605,9 +7374,30 @@ impl Interpreter { match sender { Some(tx) => { - // A closed writer task is indistinguishable from a live - // one here; a dropped frame simply means the peer left. - let _ = tx.send(WsOutbound::Text(text)); + // Reserve the frame's bytes against the per-message and + // global queued-byte budget *before* materializing the + // payload, so an oversized value is never cloned first. + match self.budget.try_reserve_ws_bytes(msg_len) { + Some(permit) => { + let text = Self::ws_message_text(&message_val, *line, *column)?; + // A closed writer task is indistinguishable from a + // live one here; a dropped frame simply means the + // peer left (or the bounded queue is saturated). + if let Err(err) = tx.try_send(WsOutbound::Text { + text, + _permit: permit, + }) { + log::warn!( + "WebSocket outbound queue full/closed for {conn_id}; dropping frame: {err}" + ); + } + } + None => { + log::warn!( + "WebSocket outbound frame for {conn_id} ({msg_len} bytes) exceeds the per-message or global queued-byte limit; dropping frame" + ); + } + } Ok((Value::Null, ControlFlow::None)) } None => Err(RuntimeError::new( @@ -6624,6 +7414,15 @@ impl Interpreter { column, } => { let message_val = self.evaluate_expression(message, Rc::clone(&env)).await?; + // Measure first; reject an oversized broadcast payload before it + // is materialized (and then cloned per recipient). + let msg_len = Self::ws_message_byte_len(&message_val, *line, *column)?; + if msg_len > self.budget.max_ws_message_bytes() { + log::warn!( + "WebSocket broadcast payload ({msg_len} bytes) exceeds the per-message limit; dropping broadcast" + ); + return Ok((Value::Null, ControlFlow::None)); + } let text = Self::ws_message_text(&message_val, *line, *column)?; let server_key = self @@ -6645,7 +7444,26 @@ impl Interpreter { if let Ok(map) = self.ws_connections.lock() { for id in ids { if let Some(tx) = map.get(&id) { - let _ = tx.send(WsOutbound::Text(text.clone())); + // Reserve each recipient's copy against the global + // queued-byte budget; shed over-budget frames rather + // than buffering them without bound. + match self.budget.try_reserve_ws_bytes(msg_len) { + Some(permit) => { + if let Err(err) = tx.try_send(WsOutbound::Text { + text: text.clone(), + _permit: permit, + }) { + log::warn!( + "WebSocket broadcast: outbound queue full/closed for {id}; dropping frame: {err}" + ); + } + } + None => { + log::warn!( + "WebSocket broadcast frame for {id} ({msg_len} bytes) exceeds the global queued-byte limit; dropping frame" + ); + } + } } } } @@ -6753,15 +7571,10 @@ impl Interpreter { line, column, } => { - // Guard against a file that (directly or indirectly) executes itself - if self.execute_depth >= MAX_EXECUTE_FILE_DEPTH { - return Err(RuntimeError::new( - format!( - "Maximum execute file nesting depth ({MAX_EXECUTE_FILE_DEPTH}) exceeded - possible circular execution" - ), - *line, - *column, - )); + // Guard against a file that (directly or indirectly) executes + // itself, using the shared budget's execute-file depth ceiling. + if let Err(exceeded) = self.budget.check_execute_file_depth(self.execute_depth) { + return Err(self.budget_error(exceeded, *line, *column)); } // Evaluate path expression to string @@ -6814,9 +7627,10 @@ impl Interpreter { let resolved_path = tokio::fs::canonicalize(&joined) .await .map_err(map_io_error)?; - let content = tokio::fs::read_to_string(&resolved_path) - .await - .map_err(map_io_error)?; + // Read under the shared source-size ceiling (bounded read). + let content = self + .read_source_bounded(&resolved_path, *line, *column) + .await?; // Evaluate the optional request context and extract the variables // that `wait for request` defines, so the executed file sees the @@ -6882,10 +7696,15 @@ impl Interpreter { }; // Parse the file; errors are catchable in the parent - use crate::lexer::lex_wfl_with_positions; + use crate::lexer::lex_wfl_with_positions_checked; use crate::parser::Parser; - let tokens = lex_wfl_with_positions(&content); + // Lex under the shared run budget: a deadline / cancellation / + // operation breach during nested source loading surfaces as a + // typed, catchable runtime error instead of a truncated token + // stream that could execute as if it were the whole file. + let tokens = lex_wfl_with_positions_checked(&content) + .map_err(|exceeded| self.budget_error(exceeded, *line, *column))?; let mut parser = Parser::new(&tokens); let program = parser.parse().map_err(|errors| { let first_error = errors.first(); @@ -6932,6 +7751,15 @@ impl Interpreter { let mut child = Interpreter::with_config(Arc::clone(&self.config)); child.set_source_file(resolved_path.clone()); child.execute_depth = self.execute_depth + 1; + // Share the parent's budget so the deadline, operation ceiling, + // and cancellation span the whole run — otherwise splitting work + // across `execute file` calls would reset them and evade the cap. + child.budget = Arc::clone(&self.budget); + // Seed the child's recursion accounting with the parent's live + // depth so the combined WFL call depth across nested `execute + // file` runs is bounded by `max_call_depth` (not multiplied per + // level), preventing native-stack overflow before the guard fires. + child.base_call_depth = self.call_depth.get(); { let mut child_env = child.global_env().borrow_mut(); @@ -6948,6 +7776,11 @@ impl Interpreter { let capture_buffer = variable_name .as_ref() .map(|_| Rc::new(RefCell::new(String::new()))); + // The child shares this budget, so the parent's active main-loop + // exemption (a depth counter, not a flag) naturally covers the + // child and the nested front end — `execute file` from inside a + // server's `main loop` handler inherits the exemption instead of + // spuriously timing out, and the RAII guard needs no save/restore. let run_result = { let _guard = capture_buffer .as_ref() @@ -7425,6 +8258,26 @@ impl Interpreter { } } + /// The byte length a `send`/`broadcast` value would serialize to, computed + /// from the *borrowed* value — for `Value::Text` (`Arc`) this is + /// `t.len()` with no allocation. Lets the queued-byte permit be reserved + /// (and an oversized message rejected) *before* the payload is cloned into a + /// `String`, so an oversized runtime value is never fully duplicated first. + fn ws_message_byte_len( + value: &Value, + line: usize, + column: usize, + ) -> Result { + match value { + Value::Text(t) => Ok(t.len()), + // Small, bounded scalars — measuring == materializing cost. + Value::Number(n) => Ok(format!("{n}").len()), + Value::Bool(b) => Ok(if *b { 3 } else { 2 }), + // Reuse `ws_message_text`'s errors for the unsupported cases. + _ => Self::ws_message_text(value, line, column).map(|s| s.len()), + } + } + /// Extracts a connection id from a `send ... to ` target. Accepts the /// connection object bound by a connect/message handler (reads its `id`). fn ws_connection_id(value: &Value, line: usize, column: usize) -> Result { @@ -7455,15 +8308,12 @@ impl Interpreter { // Snapshot the receivers with a short borrow; dispatch below must not // hold a borrow of web_socket_servers across handler execution. - let receivers: Vec<( - String, - Arc>>, - )> = self - .web_socket_servers - .borrow() - .iter() - .map(|(key, srv)| (key.clone(), Arc::clone(&srv.event_receiver))) - .collect(); + let receivers: Vec<(String, Arc>>)> = + self.web_socket_servers + .borrow() + .iter() + .map(|(key, srv)| (key.clone(), Arc::clone(&srv.event_receiver))) + .collect(); if receivers.is_empty() { tokio::time::sleep(remaining).await; @@ -8622,8 +9472,12 @@ impl Interpreter { } }; - // Perform the match - let matches = compiled_pattern.matches(text_str); + // Perform the match under the shared budget so a pathological + // pattern is bounded by the run's step/state ceilings; a breach + // surfaces as a catchable error, not a silent non-match. + let matches = compiled_pattern + .matches_with_budget(text_str, &self.budget) + .map_err(|e| self.pattern_error(e, *_line, *_column))?; Ok(Value::Bool(matches)) } @@ -8660,8 +9514,12 @@ impl Interpreter { } }; - // Find the first match - match compiled_pattern.find(text_str) { + // Find the first match under the shared budget (a breach is a + // catchable error rather than a silent non-match). + let found = compiled_pattern + .find_with_budget(text_str, &self.budget) + .map_err(|e| self.pattern_error(e, *_line, *_column))?; + match found { Some(match_result) => { // Return an object with match information let mut result_map = std::collections::HashMap::new(); @@ -9335,6 +10193,18 @@ impl Interpreter { let _ = call_env.borrow_mut().define_direct(param, arg.clone()); } + // Enforce the shared recursion ceiling before descending another level, + // turning runaway recursion into a clean error instead of a native stack + // overflow. The dedicated `call_depth` counter (not `call_stack.len()`) + // is the enforcement source of truth: it is decremented by the RAII + // guard below as the call unwinds — including when a `try`/`when` + // catches a `ResourceLimit` — so catch-and-recurse cannot under-count + // and pile onto still-live native frames. + if let Err(exceeded) = self.budget.check_call_depth(self.call_depth.get()) { + return Err(self.budget_error(exceeded, line, column)); + } + let _depth_guard = CallDepthGuard::enter(&self.call_depth); + let frame = CallFrame::new( func.name .clone() diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index 06d442d6..ae1eb520 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -11,6 +11,10 @@ pub mod token; use logos::Logos; use token::{Token, TokenWithPosition}; +/// Poll the run budget every this many lexed tokens. A power of two so the +/// stride test is a mask. +const LEX_CHECKPOINT_STRIDE: u64 = 4096; + pub fn lex_wfl(input: &str) -> Vec { // Bolt: We no longer normalize line endings globally to avoid allocation. // Token::Newline now matches \r\n, \n, and \r. @@ -102,7 +106,19 @@ pub fn lex_wfl(input: &str) -> Vec { tokens } -pub fn lex_wfl_with_positions(input: &str) -> Vec { +/// Tokenize `input` (with positions), consulting `checkpoint` every +/// [`LEX_CHECKPOINT_STRIDE`] tokens. The checkpoint returns `Err(BudgetExceeded)` +/// to abort tokenization with a typed, fatal outcome — the lexer never returns a +/// silently truncated token stream as a success. The two public entry points +/// below supply either a no-op checkpoint (non-budgeted callers: the LSP, +/// tooling, and tests) or a budget-enforcing one (production execution paths). +fn lex_positions_core( + input: &str, + mut checkpoint: F, +) -> Result, crate::exec::budget::BudgetExceeded> +where + F: FnMut() -> Result<(), crate::exec::budget::BudgetExceeded>, +{ // Bolt: We no longer normalize line endings globally to avoid allocation. // Token::Newline now matches \r\n, \n, and \r. let mut lexer = Token::lexer(input); @@ -124,8 +140,27 @@ pub fn lex_wfl_with_positions(input: &str) -> Vec { let mut current_line = 1; let mut current_column = 1; 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 + // interrupt lexing. + // Consult the budget at the boundary BEFORE the first token, so an already + // cancelled or expired budget is observed even for a short input that never + // reaches a full `LEX_CHECKPOINT_STRIDE`. Without this, `--lex` (which has no + // later parser/analyzer checkpoint) could dump a short source and exit 0 + // under an already-breached run budget. + checkpoint()?; + let mut lexed_tokens: u64 = 0; while let Some(token_result) = lexer.next() { + // Every `LEX_CHECKPOINT_STRIDE` tokens, consult the checkpoint. On a + // budget breach it returns `Err`, and `?` aborts tokenization with a + // typed, fatal outcome — the lexer never returns a truncated stream as a + // success that a later phase could mistake for a complete program. + lexed_tokens = lexed_tokens.wrapping_add(1); + if lexed_tokens & (LEX_CHECKPOINT_STRIDE - 1) == 0 { + checkpoint()?; + } let span = lexer.span(); // Calculate skipped whitespace/comments length @@ -329,6 +364,11 @@ pub fn lex_wfl_with_positions(input: &str) -> Vec { } } + // Final boundary check: catch a breach that occurred while lexing the last + // partial stride (the tokens after the highest `LEX_CHECKPOINT_STRIDE` + // multiple), which the in-loop strided check would otherwise miss. + checkpoint()?; + if let Some(id) = current_id.take() { tokens.push(TokenWithPosition::with_span( Token::Identifier(id), @@ -339,5 +379,55 @@ pub fn lex_wfl_with_positions(input: &str) -> Vec { current_id_byte_end, )); } - tokens + Ok(tokens) +} + +/// Tokenize `input` **without** budget enforcement. Used by the LSP, tooling, +/// and tests that run outside a run budget. Its checkpoint is a no-op, so it +/// never truncates and cannot fail — the silent-truncation footgun that a +/// budget breach used to create in this path no longer exists here. +pub fn lex_wfl_with_positions(input: &str) -> Vec { + match lex_positions_core(input, || Ok(())) { + Ok(tokens) => tokens, + // The no-op checkpoint never returns `Err`, so this arm is unreachable. + Err(_) => unreachable!("the no-op lexer checkpoint never breaches the budget"), + } +} + +/// Tokenize `input` under the current [`crate::exec::budget::ExecutionBudget`], +/// returning a typed [`crate::exec::budget::BudgetExceeded`] on a deadline / +/// cancellation / operation-ceiling breach instead of a silently truncated +/// token stream. Production execution paths (the CLI run and `--lex`, nested +/// `execute file` / `include` / `load module` loading, and the REPL) use this +/// and propagate the error, so a breach can never let a source *prefix* be +/// parsed, analyzed, or executed as if it were the whole program. +/// +/// The budget is consulted at the lexing boundary — before the first token and +/// after the last — and every `LEX_CHECKPOINT_STRIDE` tokens in between, so even +/// a short source under an already-cancelled or already-expired budget aborts +/// (there is no later `--lex` phase to catch it). The deadline and cancellation +/// are checked **directly** — not through `charge_operation`'s 1024-operation +/// sampling, which (nested inside the 4096-token stride) could postpone those +/// checks by millions of tokens — and the operation ceiling is charged +/// separately. +pub fn lex_wfl_with_positions_checked( + input: &str, +) -> Result, crate::exec::budget::BudgetExceeded> { + use crate::exec::budget::ExecutionBudget; + lex_positions_core(input, || { + let Some(budget) = ExecutionBudget::current() else { + return Ok(()); + }; + // Direct, every-stride deadline/cancellation checks so a breach is + // observed within one stride rather than after `charge_operation`'s + // sampling would next fire. + budget.check_cancelled()?; + let exempt = budget.is_deadline_exempt(); + if !exempt { + budget.check_deadline()?; + } + // Charge the operation ceiling separately (skipped while a `main loop` + // is active, mirroring the interpreter's exemption). + budget.charge_operation(!exempt) + }) } diff --git a/src/lib.rs b/src/lib.rs index 05e413a6..b96daca5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod config; pub mod debug_report; pub mod diagnostics; pub mod env_dump; +pub mod exec; pub mod fixer; pub mod interpreter; pub mod lexer; @@ -76,6 +77,58 @@ pub fn init_loggers(log_path: &Path, script_dir: &Path) { pub use interpreter::{Interpreter, TestFailure, TestResults}; +/// Dedicated interpreter thread stack size (1 GiB), reserved virtually and +/// committed lazily. See [`run_with_interpreter_stack`]. +pub const INTERPRETER_STACK_SIZE: usize = 1024 * 1024 * 1024; + +/// Run `work` on a dedicated large-stack thread ([`INTERPRETER_STACK_SIZE`]) and +/// return its result. +/// +/// WFL's async tree-walking interpreter recurses through several frames per WFL +/// call, so deep WFL recursion is very stack-heavy — a debug build overflows an +/// ordinary 8 MiB thread stack near depth ~40, long before the shared +/// [`exec::budget::ExecutionBudget`]'s `max_call_depth` (default 1000) can turn +/// runaway recursion into a clean, catchable `ResourceLimit` error. The CLI runs +/// its whole runtime on such a thread; **a library embedder that drives +/// [`Interpreter`] directly should do the same** — otherwise a deep WFL program +/// can crash the host process with a native stack overflow that no depth limit +/// can catch, because the limit only protects a stack large enough to reach it. +/// +/// Wrap the runtime + interpreter call in this helper, e.g.: +/// +/// ```no_run +/// let exit = wfl::run_with_interpreter_stack(|| { +/// let rt = tokio::runtime::Builder::new_current_thread() +/// .enable_all() +/// .build() +/// .expect("runtime"); +/// rt.block_on(async { +/// // build an Interpreter and call `interpret(...)` here +/// }) +/// }) +/// .expect("reserve interpreter stack"); +/// # let _ = exit; +/// ``` +/// +/// Returns `Err` only if the large-stack thread cannot be spawned (e.g. a tight +/// `RLIMIT_AS` or a 32-bit target); a caller may then fall back to running the +/// 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(work: F) -> std::io::Result +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, +{ + let handle = std::thread::Builder::new() + .name("wfl-interpreter".to_string()) + .stack_size(INTERPRETER_STACK_SIZE) + .spawn(work)?; + Ok(handle + .join() + .unwrap_or_else(|payload| std::panic::resume_unwind(payload))) +} + pub fn add(left: u64, right: u64) -> u64 { left + right } diff --git a/src/main.rs b/src/main.rs index d00ed29f..0d16ee00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,12 +10,12 @@ use wfl::config; use wfl::debug_report; use wfl::diagnostics::{DiagnosticReporter, Severity}; use wfl::fixer::{CodeFixer, FixerOutputMode}; -use wfl::lexer::lex_wfl_with_positions; +use wfl::lexer::lex_wfl_with_positions_checked; use wfl::linter::Linter; use wfl::parser::Parser; use wfl::repl; use wfl::transpiler::{TranspilerConfig, TranspilerTarget}; -use wfl::typechecker::TypeChecker; +use wfl::typechecker::{TypeCheckError, TypeChecker}; use wfl::wfl_config; use wfl::{error, exec_trace, info}; @@ -100,8 +100,75 @@ fn parse_create_project_args(args: &[String]) -> Option { } } -#[tokio::main] -async fn main() -> io::Result<()> { +/// Stack size for the thread that runs the interpreter. +/// +fn build_runtime() -> io::Result { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() +} + +fn main() -> io::Result<()> { + // Trivial, non-interpreting invocations (`--help`, `--version`) never + // recurse, so run them on the ordinary stack — don't make printing help + // depend on reserving a large stack (which can fail under a tight + // address-space limit or on a 32-bit target). + let arg1 = std::env::args().nth(1); + let trivial = matches!( + arg1.as_deref(), + Some("--help" | "-h" | "--version" | "-v" | "-V") + ); + if trivial { + return build_runtime()?.block_on(run()); + } + + // Otherwise run on a dedicated large-stack thread (the shared + // `wfl::run_with_interpreter_stack` helper, also intended for library + // embedders) so the shared budget's `max_call_depth` turns runaway recursion + // into a clean, catchable error instead of a native stack overflow. If that + // reservation fails (tight RLIMIT_AS / 32-bit), fall back to the default + // stack rather than refusing to start — shallow programs and non-interpreting + // commands still work. + match wfl::run_with_interpreter_stack(|| build_runtime()?.block_on(run())) { + Ok(result) => result, + Err(e) => { + eprintln!( + "warning: could not reserve a large interpreter stack ({e}); \ + using the default stack (deep recursion may hit the OS limit \ + before max_call_depth)" + ); + build_runtime()?.block_on(run()) + } + } +} + +/// Read a WFL source file under the shared source-size ceiling. Reads at most +/// `max_source_size + 1` bytes so an oversized file is refused (exit code 2) +/// without ever allocating the whole thing — even when the file's metadata is +/// unavailable, stale, or reports `0` (special files). +fn read_source_bounded( + path: &str, + budget: &wfl::exec::budget::ExecutionBudget, +) -> io::Result { + use std::io::Read; + let max = budget.max_source_bytes(); + let read_cap = (max as u64).saturating_add(1); + let file = fs::File::open(path)?; + let mut buf = Vec::new(); + file.take(read_cap).read_to_end(&mut buf)?; + if let Err(exceeded) = budget.check_source_bytes(buf.len()) { + eprintln!("Error: {}", exceeded.message()); + process::exit(2); + } + String::from_utf8(buf).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("source file '{path}' is not valid UTF-8"), + ) + }) +} + +async fn run() -> io::Result<()> { // Initialize dhat profiler if enabled #[cfg(feature = "dhat-heap")] let _profiler = dhat::Profiler::new_heap(); @@ -736,13 +803,49 @@ async fn main() -> io::Result<()> { return Ok(()); } - 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); + // Build the ONE execution budget for this run up front, from the same + // (timeout-capped) config the interpreter will use, so a single budget + // governs the pre-parse source check, lexing/parsing/analysis, and + // interpretation — its deadline clock starts here and covers the whole run. + let mut run_config = config.clone(); + run_config.timeout_seconds = run_config.timeout_seconds.min(300); + let run_config = std::sync::Arc::new(run_config); + let budget = std::sync::Arc::new(wfl::exec::budget::ExecutionBudget::from_config(&run_config)); + + // Install the run budget as the current-thread budget for the ENTIRE run, so + // every front-end phase — lexing, parsing, analysis, type checking — and the + // dump/`--analyze` modes consult *one* budget and honor its deadline and + // cooperative cancellation, not just the interpreter. The parser and the + // analyzer/type-checker read it via `ExecutionBudget::current()` at their + // top-level checkpoints; the interpreter re-enters the same budget when it + // runs. Held for the whole function (restored on drop). + let _run_budget_guard = + wfl::exec::budget::ExecutionBudget::enter(std::sync::Arc::clone(&budget)); + + // Read the source under the shared source-size ceiling: read at most + // `max_source_size + 1` bytes so an oversized file is refused without ever + // allocating the whole thing (this holds even if metadata is unavailable). + let input = read_source_bounded(&file_path, &budget)?; + + // Lex under the shared run budget (installed above): a deadline / + // cancellation / operation-ceiling breach during tokenization surfaces as a + // fatal error and exits, instead of returning a silently truncated token + // stream that a later phase could parse, analyze, or execute as if it were + // the whole program. Used by every source-lexing mode below. + let lex_checked = |source: &str| match lex_wfl_with_positions_checked(source) { + Ok(tokens) => tokens, + Err(exceeded) => { + eprintln!("Error: {}", exceeded.message()); + process::exit(2); + } + }; + // Handle lexer and AST dump flags if lex_dump || ast_dump { - let tokens_with_pos = lex_wfl_with_positions(&input); + let tokens_with_pos = lex_checked(&input); // Function to write data to a file with appropriate error handling fn write_to_file(path: &str, content: &str) -> io::Result<()> { @@ -853,7 +956,7 @@ async fn main() -> io::Result<()> { // Handle transpile mode if transpile_mode { - let tokens_with_pos = lex_wfl_with_positions(&input); + let tokens_with_pos = lex_checked(&input); match Parser::new(&tokens_with_pos).parse() { Ok(program) => { // Configure the transpiler @@ -925,7 +1028,7 @@ async fn main() -> io::Result<()> { } if lint_mode { - let tokens_with_pos = lex_wfl_with_positions(&input); + let tokens_with_pos = lex_checked(&input); match Parser::new(&tokens_with_pos).parse() { Ok(program) => { let mut linter = Linter::new(); @@ -986,7 +1089,7 @@ async fn main() -> io::Result<()> { } } } else if analyze_mode { - let tokens_with_pos = lex_wfl_with_positions(&input); + let tokens_with_pos = lex_checked(&input); match Parser::new(&tokens_with_pos).parse() { Ok(program) => { let mut analyzer = Analyzer::new(); @@ -1032,7 +1135,7 @@ async fn main() -> io::Result<()> { } } } else if fix_mode { - let tokens_with_pos = lex_wfl_with_positions(&input); + let tokens_with_pos = lex_checked(&input); match Parser::new(&tokens_with_pos).parse() { Ok(_program) => { let mut fixer = CodeFixer::new(); @@ -1079,7 +1182,7 @@ async fn main() -> io::Result<()> { } } } else { - let tokens_with_pos = lex_wfl_with_positions(&input); + let tokens_with_pos = lex_checked(&input); // Initialize both regular and execution logging first so debug output goes to log let log_path = script_dir.join("wfl.log"); @@ -1122,52 +1225,65 @@ async fn main() -> io::Result<()> { // Create TypeChecker with the same analyzer to share action parameters let mut tc = TypeChecker::with_analyzer(analyzer); - if let Err(errors) = tc.check_types(&program) { - // Filter out errors for action parameters - let action_params = tc.get_action_parameters(); - let filtered_errors: Vec<_> = errors - .into_iter() - .filter(|e| { - // Check if this is an undefined variable error for an action parameter - if e.message.starts_with("Variable '") - && e.message.ends_with("' is not defined") - { - let var_name = e - .message - .trim_start_matches("Variable '") - .trim_end_matches("' is not defined"); - - // Skip this error if the variable is an action parameter - if action_params.contains(var_name) { - return false; - } - } + if let Err(failure) = tc.check_types(&program) { + match failure { + // A shared-budget breach during type checking is FATAL — + // the type diagnostics below are otherwise treated as + // non-fatal warnings, which would let an expired deadline + // / cancellation / resource breach slip into execution. + TypeCheckError::Budget(exceeded) => { + eprintln!("Error: {}", exceeded.message()); + process::exit(2); + } + TypeCheckError::Types(errors) => { + // Filter out errors for action parameters + let action_params = tc.get_action_parameters(); + let filtered_errors: Vec<_> = errors + .into_iter() + .filter(|e| { + // Check if this is an undefined variable error for an action parameter + if e.message.starts_with("Variable '") + && e.message.ends_with("' is not defined") + { + let var_name = e + .message + .trim_start_matches("Variable '") + .trim_end_matches("' is not defined"); + + // Skip this error if the variable is an action parameter + if action_params.contains(var_name) { + return false; + } + } - // Filter out "Symbol already defined" errors at line 0, column 0 - // These are likely from imported files or standard library definitions - if e.message.starts_with("Symbol '") - && e.message.contains("' is already defined in this scope") - && e.line == 0 - && e.column == 0 - { - return false; - } + // Filter out "Symbol already defined" errors at line 0, column 0 + // These are likely from imported files or standard library definitions + if e.message.starts_with("Symbol '") + && e.message.contains("' is already defined in this scope") + && e.line == 0 + && e.column == 0 + { + return false; + } - true - }) - .collect(); + true + }) + .collect(); - if !filtered_errors.is_empty() { - eprintln!("Type checking warnings:"); + if !filtered_errors.is_empty() { + eprintln!("Type checking warnings:"); - let mut reporter = DiagnosticReporter::new(); - let file_id = reporter.add_file(&file_path, &input); + let mut reporter = DiagnosticReporter::new(); + let file_id = reporter.add_file(&file_path, &input); - for error in &filtered_errors { - let diagnostic = reporter.convert_type_error(file_id, error); - if let Err(e) = reporter.report_diagnostic(file_id, &diagnostic) { - eprintln!("Error displaying diagnostic: {e}"); - eprintln!("{error}"); // Fallback to simple error display + for error in &filtered_errors { + let diagnostic = reporter.convert_type_error(file_id, error); + if let Err(e) = reporter.report_diagnostic(file_id, &diagnostic) + { + eprintln!("Error displaying diagnostic: {e}"); + eprintln!("{error}"); // Fallback to simple error display + } + } } } } @@ -1180,18 +1296,17 @@ async fn main() -> io::Result<()> { // Log execution start if execution logging is enabled exec_trace!("Starting execution of script: {}", &file_path); - // Pass the full loaded configuration (not just the timeout) so that - // settings like `web_server_bind_address` from `.wflcfg` actually reach - // the interpreter. `config.clone()` is needed because `config` is read - // again later (e.g. `if config.logging_enabled`). See issue #466. - // - // Preserve the 300-second execution-timeout safety cap that the previous - // `Interpreter::with_timeout` path enforced, so this change only *adds* - // config propagation without altering timeout semantics. (The cap never - // affects web servers: `check_time` skips the timeout inside a main loop.) - let mut run_config = config.clone(); - run_config.timeout_seconds = run_config.timeout_seconds.min(300); - let mut interpreter = Interpreter::with_config(std::sync::Arc::new(run_config)); + // Reuse the single budget (and the timeout-capped `run_config`) + // built at the top of the run, so the source check and the + // interpreter share one deadline/operation/cancellation budget. + // `run_config` carries the full `.wflcfg` (e.g. + // `web_server_bind_address`) with the 300s timeout cap applied + // (see issue #466); the cap never affects web servers because + // `check_time` skips the deadline inside a main loop. + let mut interpreter = Interpreter::with_config_and_budget( + std::sync::Arc::clone(&run_config), + std::sync::Arc::clone(&budget), + ); interpreter.set_step_mode(step_mode); // Set step mode from CLI flag interpreter.set_test_mode(test_mode); // Set test mode from CLI flag interpreter.set_script_args(script_args); // Pass script arguments diff --git a/src/parser/expr/primary.rs b/src/parser/expr/primary.rs index 8ed08974..aa58b1b8 100644 --- a/src/parser/expr/primary.rs +++ b/src/parser/expr/primary.rs @@ -20,6 +20,10 @@ pub(crate) trait PrimaryExprParser<'a> { impl<'a> PrimaryExprParser<'a> for Parser<'a> { fn parse_primary_expression(&mut self) -> Result { + // Strided run-budget checkpoint. Every operand (list element, operator- + // chain term, call argument) routes through here, so this bounds a single + // huge expression that the statement-boundary checkpoint would miss. + self.charge_parse_step()?; if let Some(token) = self.cursor.peek() { let result = match &token.token { Token::LeftBracket => { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index bb9a730c..a25d2213 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -17,11 +17,20 @@ use stmt::{ StmtParser, TestingParser, VariableParser, WebParser, }; +/// Poll the run budget every this many primary-expression parses. The +/// statement-boundary checkpoint does not cover one giant expression (a +/// million-element list, a long flat operator chain), so this bounds the +/// intra-statement token work too. A power of two so the stride test is a mask. +const PARSE_CHECKPOINT_STRIDE: u64 = 2048; + pub struct Parser<'a> { /// Cursor for efficient token navigation cursor: Cursor<'a>, /// Parse errors accumulated during parsing errors: Vec, + /// Count of primary-expression parses, for the strided budget checkpoint + /// (see [`Parser::charge_parse_step`]). + parse_steps: u64, } impl<'a> Parser<'a> { @@ -29,6 +38,7 @@ impl<'a> Parser<'a> { Parser { cursor: Cursor::new(tokens), errors: Vec::with_capacity(4), + parse_steps: 0, } } @@ -41,6 +51,28 @@ impl<'a> Parser<'a> { self.cursor.bump() } + /// Strided run-budget checkpoint for expression parsing. Called at every + /// primary-expression parse; every [`PARSE_CHECKPOINT_STRIDE`] calls it + /// consults the run budget (exemption-aware) so a single huge expression is + /// interruptible on a deadline / cancellation / operation breach, not just at + /// statement boundaries. Returns the breach as a `ParseError` to abort the + /// parse; a no-op (returns `Ok`) when no run budget is installed. + #[inline] + fn charge_parse_step(&mut self) -> Result<(), ParseError> { + self.parse_steps = self.parse_steps.wrapping_add(1); + if self.parse_steps & (PARSE_CHECKPOINT_STRIDE - 1) == 0 + && let Some(budget) = crate::exec::budget::ExecutionBudget::current() + && let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) + && let Some(token) = self.cursor.peek() + { + // Anchor the breach to the current token. At EOF (no token) there is + // nothing left to parse, so the downstream analyzer/type-checker + // checkpoint surfaces the breach instead. + return Err(ParseError::from_token(exceeded.message(), token)); + } + Ok(()) + } + pub fn parse(&mut self) -> Result> { let mut program = Program::new(); program.statements.reserve(self.cursor.remaining() / 5); @@ -48,6 +80,22 @@ impl<'a> Parser<'a> { while self.cursor.peek().is_some() { let start_pos = self.cursor.pos(); + // Front-end budget checkpoint: consult the shared run budget (if a run + // installed one on this thread) once per top-level statement, so the + // wall-clock deadline and cooperative cancellation are honored *during* + // parsing — not merely measured for later interpretation — and a + // pathological or oversized parse can be aborted cleanly. + if let Some(budget) = crate::exec::budget::ExecutionBudget::current() + && let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) + && let Some(token) = self.cursor.peek() + { + // `peek()` is `Some` here (the loop condition), so a position is + // always available for the diagnostic. + self.errors + .push(ParseError::from_token(exceeded.message(), token)); + break; + } + // Skip any leading Eol tokens if let Some(token) = self.cursor.peek() && matches!(token.token, Token::Eol) @@ -405,6 +453,20 @@ impl<'a> Parser<'a> { // Implementation of StmtParser trait impl<'a> StmtParser<'a> for Parser<'a> { fn parse_statement(&mut self) -> Result { + // Recursive front-end checkpoint: `parse_statement` is called for *every* + // statement, including deeply nested block bodies the top-level `parse` + // loop never revisits, so consulting the run budget here keeps a large + // nested parse interruptible (deadline/cancellation, exemption-aware). + if let Some(budget) = crate::exec::budget::ExecutionBudget::current() + && let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) + && self.cursor.peek().is_some() + { + let err = ParseError::from_token(exceeded.message(), self.cursor.peek().unwrap()); + // Advance one token so the top-level `parse` loop's no-progress + // invariant holds even when the breach fires on the first statement. + self.bump_sync(); + return Err(err); + } if let Some(token) = self.cursor.peek() { match &token.token { Token::KeywordStore => self.parse_variable_declaration(), diff --git a/src/pattern/mod.rs b/src/pattern/mod.rs index 1c2f19c7..d4adec98 100644 --- a/src/pattern/mod.rs +++ b/src/pattern/mod.rs @@ -46,6 +46,8 @@ pub mod compiler; pub mod instruction; pub mod vm; +use crate::exec::budget::ExecutionBudget; + pub use compiler::PatternCompiler; pub use instruction::{Instruction, Program as PatternProgram}; pub use vm::{MatchResult, PatternVM}; @@ -65,6 +67,18 @@ pub enum PatternError { RuntimeError(String), /// Pattern execution exceeded the maximum allowed steps (prevents ReDoS) StepLimitExceeded, + /// Pattern execution exceeded the maximum allowed simultaneously-active + /// states (guards against exponential state fan-out) + StateLimitExceeded, + /// Pattern execution exceeded the run's wall-clock deadline. Carries the + /// configured limit in seconds so it can surface as the historic timeout + /// error rather than a ReDoS step-limit error. + Timeout { + /// The configured wall-clock limit, in seconds. + limit_secs: u64, + }, + /// Pattern execution was cancelled via the shared `ExecutionBudget` + Cancelled, /// Referenced capture group does not exist InvalidCapture(String), /// Invalid bytecode instruction encountered @@ -77,6 +91,15 @@ impl std::fmt::Display for PatternError { PatternError::CompileError(msg) => write!(f, "Pattern compile error: {msg}"), PatternError::RuntimeError(msg) => write!(f, "Pattern runtime error: {msg}"), PatternError::StepLimitExceeded => write!(f, "Pattern execution step limit exceeded"), + PatternError::StateLimitExceeded => { + write!(f, "Pattern execution active-state limit exceeded") + } + // Reuse the interpreter's historic timeout wording so a pattern that + // outruns the deadline reports the same message as any other timeout. + PatternError::Timeout { limit_secs } => { + write!(f, "Execution exceeded timeout ({limit_secs}s)") + } + PatternError::Cancelled => write!(f, "Pattern execution was cancelled"), PatternError::InvalidCapture(name) => write!(f, "Invalid capture group: {name}"), PatternError::InvalidInstruction(msg) => write!(f, "Invalid instruction: {msg}"), } @@ -208,11 +231,11 @@ impl CompiledPattern { /// ``` /// /// # Note - /// Execution errors are silently converted to `false`. For error details, - /// use the VM directly. + /// Execution errors (including a ReDoS/budget breach) are silently converted + /// to `false`. Use [`CompiledPattern::matches_with_budget`] to observe them. pub fn matches(&self, text: &str) -> bool { - let mut vm = PatternVM::new(); - vm.execute(&self.program, text).unwrap_or(false) + self.matches_with_budget(text, &ExecutionBudget::current_or_default()) + .unwrap_or(false) } /// Find the first match in the text with position and capture information. @@ -235,12 +258,16 @@ impl CompiledPattern { /// # let pattern = CompiledPattern::compile(&pattern).unwrap(); /// let text = "say hello world"; /// if let Some(m) = pattern.find(text) { - /// println!("Match: '{}' at {}-{}", &text[m.start..m.end], m.start, m.end); + /// // `m.start`/`m.end` are CHARACTER indices, not byte offsets, so + /// // extract by chars — slicing `&text[m.start..m.end]` would panic on + /// // any index that is not a UTF-8 byte boundary. + /// let matched: String = text.chars().skip(m.start).take(m.end - m.start).collect(); + /// println!("Match: '{}' at {}-{}", matched, m.start, m.end); /// } /// ``` pub fn find(&self, text: &str) -> Option { - let mut vm = PatternVM::new(); - vm.find(&self.program, text, &self.capture_names) + self.find_with_budget(text, &ExecutionBudget::current_or_default()) + .unwrap_or(None) } /// Find all non-overlapping matches in the text. @@ -268,7 +295,44 @@ impl CompiledPattern { /// For patterns that may match many times, consider using iterative /// approaches if memory usage is a concern. pub fn find_all(&self, text: &str) -> Vec { - let mut vm = PatternVM::new(); - vm.find_all(&self.program, text, &self.capture_names) + self.find_all_with_budget(text, &ExecutionBudget::current_or_default()) + .unwrap_or_default() + } + + /// [`CompiledPattern::matches`], sharing an existing [`ExecutionBudget`] so + /// the match respects the run's pattern step/state ceilings, wall-clock + /// deadline, and cooperative cancellation, and **propagating** a budget + /// breach instead of hiding it as a non-match. Each call runs on a fresh + /// per-match meter, so concurrent matches under one run budget never + /// interfere. + pub fn matches_with_budget( + &self, + text: &str, + budget: &std::sync::Arc, + ) -> Result { + let mut vm = PatternVM::with_budget(std::sync::Arc::clone(budget)); + vm.execute(&self.program, text) + } + + /// [`CompiledPattern::find`], sharing an existing [`ExecutionBudget`] and + /// propagating budget breaches on a fresh per-match meter. + pub fn find_with_budget( + &self, + text: &str, + budget: &std::sync::Arc, + ) -> Result, PatternError> { + let mut vm = PatternVM::with_budget(std::sync::Arc::clone(budget)); + vm.try_find(&self.program, text, &self.capture_names) + } + + /// [`CompiledPattern::find_all`], sharing an existing [`ExecutionBudget`] and + /// propagating budget breaches on a fresh per-match meter. + pub fn find_all_with_budget( + &self, + text: &str, + budget: &std::sync::Arc, + ) -> Result, PatternError> { + let mut vm = PatternVM::with_budget(std::sync::Arc::clone(budget)); + vm.try_find_all(&self.program, text, &self.capture_names) } } diff --git a/src/pattern/vm.rs b/src/pattern/vm.rs index 7b7688b0..d78c2a58 100644 --- a/src/pattern/vm.rs +++ b/src/pattern/vm.rs @@ -7,11 +7,25 @@ use super::PatternError; use super::instruction::{Instruction, Program}; +use crate::exec::budget::{BudgetExceeded, ExecutionBudget, PatternMeter}; use std::collections::HashMap; +use std::sync::Arc; -/// Maximum number of execution steps to prevent ReDoS (Regular Expression Denial of Service) attacks. -/// This limit ensures that malicious or poorly designed patterns cannot cause infinite loops. -const MAX_STEPS: usize = 100_000; +/// Translate a per-match meter breach into the pattern VM's error type. +/// +/// The meter enforces the pattern step ceiling, the active-state ceiling, +/// cooperative cancellation, and — unless the match is exempt (inside a +/// `main loop`) — the wall-clock deadline. A deadline breach is mapped to the +/// timeout variant (so it surfaces as the historic `[Timeout]` error), not the +/// step-limit error. +fn budget_to_pattern_error(exceeded: BudgetExceeded) -> PatternError { + match exceeded { + BudgetExceeded::PatternStates { .. } => PatternError::StateLimitExceeded, + BudgetExceeded::Deadline { limit_secs } => PatternError::Timeout { limit_secs }, + BudgetExceeded::Cancelled => PatternError::Cancelled, + _ => PatternError::StepLimitExceeded, + } +} /// Result of a pattern match operation. /// @@ -89,6 +103,28 @@ impl MatchResult { captures, } } + + /// Create a match result from the already-materialized character slice, + /// avoiding a fresh `text.chars().collect()`. Used on the hot path where the + /// VM has collected the input once up front (see [`PatternVM`] runners). + fn from_chars( + start: usize, + end: usize, + chars: &[char], + captures: HashMap, + ) -> Self { + let matched_text = if start <= end && end <= chars.len() { + chars[start..end].iter().collect() + } else { + String::new() + }; + Self { + start, + end, + matched_text, + captures, + } + } } /// Virtual machine state for pattern execution @@ -131,29 +167,120 @@ impl VMState { /// different VM instances concurrently. However, a single VM instance should /// not be used from multiple threads simultaneously. pub struct PatternVM { - /// Count of execution steps to prevent infinite loops - step_count: usize, + /// The per-match meter owning this match's transition and active-state + /// counters. It borrows the run's ceilings, wall-clock deadline, and + /// cancellation flag from the shared [`ExecutionBudget`], but its counters + /// are private to this top-level match — nested lookaround/lookbehind VMs + /// clone the *same* meter (via [`PatternVM::with_meter`]) so their work + /// counts against the enclosing match, while a second, unrelated match under + /// the same run budget gets an independent meter. + meter: Arc, /// Debug flag for test mode (only available in test builds) #[cfg(test)] debug: bool, } impl PatternVM { + /// A VM bound to the current-thread run budget (see + /// [`ExecutionBudget::current_or_default`]), so stdlib pattern builtins and + /// [`super::CompiledPattern`]'s convenience methods honour the run's + /// configured `max_pattern_steps` / `max_pattern_states` ceilings — and fall + /// back to a bounded default when no run is active. pub fn new() -> Self { + Self::with_budget(ExecutionBudget::current_or_default()) + } + + /// A VM that shares an existing [`ExecutionBudget`], on a fresh per-match + /// meter, so a pattern match respects the same step/state ceilings, deadline, + /// and cancellation as the run that launched it. + pub fn with_budget(budget: Arc) -> Self { + Self::with_meter(PatternMeter::new(budget)) + } + + /// A VM that shares an existing per-match [`PatternMeter`]. Used to spawn + /// nested lookaround/lookbehind VMs so their transitions and active states + /// count against the enclosing match's ceilings. + pub(crate) fn with_meter(meter: Arc) -> Self { Self { - step_count: 0, + meter, #[cfg(test)] debug: false, } } - /// Execute a pattern program against input text (just test if it matches) + /// Execute a pattern program against input text (just test if it matches), + /// resetting this VM's per-match meter first so a reused VM does not carry + /// transitions from an unrelated prior match. pub fn execute(&mut self, program: &Program, text: &str) -> Result { - self.step_count = 0; + self.meter.reset(); + self.run_execute(program, text) + } + + /// Find the first match in the text — **backward-compatible** wrapper that + /// returns `Option` and silently converts a budget/ReDoS breach to `None`. + /// Use [`PatternVM::try_find`] to observe breaches. + pub fn find( + &mut self, + program: &Program, + text: &str, + capture_names: &[String], + ) -> Option { + self.try_find(program, text, capture_names).unwrap_or(None) + } + + /// Find all matches in the text — **backward-compatible** wrapper that + /// returns `Vec` and silently converts a budget/ReDoS breach to an empty + /// result. Use [`PatternVM::try_find_all`] to observe breaches. + pub fn find_all( + &mut self, + program: &Program, + text: &str, + capture_names: &[String], + ) -> Vec { + self.try_find_all(program, text, capture_names) + .unwrap_or_default() + } + + /// Find the first match, resetting this VM's per-match meter first and + /// **propagating** a budget/ReDoS breach. + pub fn try_find( + &mut self, + program: &Program, + text: &str, + capture_names: &[String], + ) -> Result, PatternError> { + self.meter.reset(); + self.run_find(program, text, capture_names) + } + + /// Find all matches, resetting this VM's per-match meter first and + /// **propagating** a budget/ReDoS breach. + pub fn try_find_all( + &mut self, + program: &Program, + text: &str, + capture_names: &[String], + ) -> Result, PatternError> { + self.meter.reset(); + self.run_find_all(program, text, capture_names) + } - // Try matching at each position in the text - for start_pos in 0..=text.len() { - if self.execute_at_position(program, text, start_pos)? { + /// Position-loop for [`PatternVM::execute`] that does **not** reset the + /// meter, so nested lookaround/lookbehind VMs share the enclosing match's + /// budget rather than getting a fresh quota. + fn run_execute(&mut self, program: &Program, text: &str) -> Result { + // Checkpoint *before* the O(text) preprocessing: pattern input is a + // runtime value that is not bounded by `max_source_size` (it can be an + // unbounded file read, client data, or a constructed string), so an + // already-expired deadline or cancelled run must not pay to materialize + // a large input first. Then collect the input into `Vec` exactly + // once and reuse the slice for every position and every step, instead of + // re-collecting O(text) on each transition. + self.meter.charge_step().map_err(budget_to_pattern_error)?; + let chars: Vec = text.chars().collect(); + // Try matching at each character position in the text. + for start_pos in 0..=chars.len() { + if self.execute_at_position(program, &chars, start_pos)? { return Ok(true); } } @@ -161,40 +288,45 @@ impl PatternVM { Ok(false) } - /// Find the first match in the text - pub fn find( + /// Position-loop for [`PatternVM::try_find`]; does not reset the meter (see + /// [`PatternVM::run_execute`]). + fn run_find( &mut self, program: &Program, text: &str, capture_names: &[String], - ) -> Option { - self.step_count = 0; - - // Try matching at each position in the text - for start_pos in 0..=text.len() { - if let Ok(Some(result)) = self.find_at_position(program, text, start_pos, capture_names) + ) -> Result, PatternError> { + // Checkpoint before preprocessing, then collect once (see `run_execute`). + self.meter.charge_step().map_err(budget_to_pattern_error)?; + let chars: Vec = text.chars().collect(); + // Try matching at each character position in the text. + for start_pos in 0..=chars.len() { + if let Some(result) = + self.find_at_position(program, &chars, start_pos, capture_names)? { - return Some(result); + return Ok(Some(result)); } } - None + Ok(None) } - /// Find all matches in the text - pub fn find_all( + /// Position-loop for [`PatternVM::try_find_all`]; does not reset the meter + /// (see [`PatternVM::run_execute`]). + fn run_find_all( &mut self, program: &Program, text: &str, capture_names: &[String], - ) -> Vec { + ) -> Result, PatternError> { + // Checkpoint before preprocessing, then collect once (see `run_execute`). + self.meter.charge_step().map_err(budget_to_pattern_error)?; + let chars: Vec = text.chars().collect(); let mut matches = Vec::new(); let mut pos = 0; - while pos <= text.len() { - self.step_count = 0; - - if let Ok(Some(result)) = self.find_at_position(program, text, pos, capture_names) { + while pos <= chars.len() { + if let Some(result) = self.find_at_position(program, &chars, pos, capture_names)? { pos = if result.end > result.start { result.end // Move past this match } else { @@ -206,14 +338,14 @@ impl PatternVM { } } - matches + Ok(matches) } /// Execute pattern starting at a specific position fn execute_at_position( &mut self, program: &Program, - text: &str, + chars: &[char], start_pos: usize, ) -> Result { let initial_state = VMState::new(program.num_captures, program.num_saves); @@ -221,18 +353,31 @@ impl PatternVM { pos: start_pos, ..initial_state }]; + // Reserve state slots against the per-match meter. The current and the + // next generation coexist in memory during the inner loop, and nested + // lookaround/lookbehind frontiers (which share this meter) stack on top, + // so `res` counts *all* simultaneously-live states. One reservation is + // grown as the next generation is built and shrunk when the consumed + // generation is released, so it drops correctly on every exit path. + let mut res = self + .meter + .reserve_states(states.len()) + .map_err(budget_to_pattern_error)?; while !states.is_empty() { - self.step_count += 1; - if self.step_count > MAX_STEPS { - return Err(PatternError::StepLimitExceeded); - } - + let consumed = states.len(); let mut next_states = Vec::new(); for state in states { - match self.step(program, text, state)? { + // Each transition is charged inside `step()` (per instruction), + // so an epsilon-jump cycle is bounded too. + match self.step(program, chars, state)? { StepResult::Continue(new_states) => { + // Fail fast on exponential state fan-out: reserve slots + // for the new states as the generation is built, not + // once it is complete. + res.grow(new_states.len()) + .map_err(budget_to_pattern_error)?; next_states.extend(new_states); } StepResult::Match(_) => { @@ -244,6 +389,7 @@ impl PatternVM { } } + res.release(consumed); // the previous generation is now consumed states = next_states; } @@ -254,7 +400,7 @@ impl PatternVM { fn find_at_position( &mut self, program: &Program, - text: &str, + chars: &[char], start_pos: usize, capture_names: &[String], ) -> Result, PatternError> { @@ -263,42 +409,47 @@ impl PatternVM { pos: start_pos, ..initial_state }]; + // See `execute_at_position`: one reservation counts all live states + // across current + next + nested frontiers, grown/shrunk per generation. + let mut res = self + .meter + .reserve_states(states.len()) + .map_err(budget_to_pattern_error)?; while !states.is_empty() { - self.step_count += 1; - if self.step_count > MAX_STEPS { - return Err(PatternError::StepLimitExceeded); - } - + let consumed = states.len(); let mut next_states = Vec::new(); for state in states { - match self.step(program, text, state)? { + // Transitions are charged inside `step()` (per instruction). + match self.step(program, chars, state)? { StepResult::Continue(new_states) => { + // Fail fast on exponential state fan-out. + res.grow(new_states.len()) + .map_err(budget_to_pattern_error)?; next_states.extend(new_states); } StepResult::Match(final_state) => { // Found a match, construct result with captures let mut captures: HashMap = HashMap::new(); - // Extract captures from the final state - let text_chars: Vec = text.chars().collect(); + // Extract captures from the final state, reusing the + // already-collected character slice (no re-collect). for (i, name) in capture_names.iter().enumerate() { if let Some((start, end)) = final_state.captures[i] { - let captured_text: String = - if start <= end && end <= text_chars.len() { - text_chars[start..end].iter().collect() - } else { - String::new() - }; + let captured_text: String = if start <= end && end <= chars.len() { + chars[start..end].iter().collect() + } else { + String::new() + }; captures.insert(name.clone(), captured_text); } } - return Ok(Some(MatchResult::with_captures( + return Ok(Some(MatchResult::from_chars( start_pos, final_state.pos, - text, + chars, captures, ))); } @@ -308,23 +459,35 @@ impl PatternVM { } } + res.release(consumed); // the previous generation is now consumed states = next_states; } Ok(None) } - /// Execute one step of the virtual machine + /// Execute one step of the virtual machine. + /// + /// `chars` is the input already materialized once by the calling runner, so + /// this hot path performs no per-step `text.chars().collect()` (which would + /// be O(text) on every transition for unbounded runtime input). #[allow(clippy::only_used_in_recursion)] fn step( &mut self, program: &Program, - text: &str, + chars: &[char], mut state: VMState, ) -> Result { - let chars: Vec = text.chars().collect(); - loop { + // Charge one transition per dispatched instruction against the + // per-match meter. This is the real ReDoS guard: it bounds every + // instruction chain (including epsilon-`Jump` cycles that never + // consume input) and, because nested lookaround/lookbehind VMs share + // this meter, counts their work against the same match. It also + // samples the wall-clock deadline (unless exempt), so a single + // synchronous match cannot run past `timeout_seconds`. + self.meter.charge_step().map_err(budget_to_pattern_error)?; + let instruction = match program.get(state.pc) { Some(inst) => inst, None => return Ok(StepResult::Fail), // Invalid PC @@ -519,15 +682,17 @@ impl PatternVM { ); } - // Try to match the lookahead pattern at the current position - let mut lookahead_vm = PatternVM::new(); + // Try to match the lookahead pattern at the current position. + // The nested VM shares this match's meter, so its transitions + // and active states count against the same ceilings. + let mut lookahead_vm = PatternVM::with_meter(Arc::clone(&self.meter)); #[cfg(test)] { lookahead_vm.debug = self.debug; } let lookahead_matched = - lookahead_vm.execute_at_position(&lookahead_program, text, state.pos)?; + lookahead_vm.execute_at_position(&lookahead_program, chars, state.pos)?; if lookahead_matched { #[cfg(test)] @@ -562,8 +727,16 @@ impl PatternVM { let mut depth = 1; let mut current_states = vec![lookahead_state]; let mut any_matched = false; + // Reserve the negative-lookahead frontier against the same + // per-match meter, so its states add to (rather than escape) + // the enclosing match's active-state ceiling. + let mut res = self + .meter + .reserve_states(current_states.len()) + .map_err(budget_to_pattern_error)?; 'outer: while depth > 0 && !current_states.is_empty() { + let consumed = current_states.len(); let mut next_states = Vec::new(); for lookahead_state in current_states.drain(..) { @@ -586,11 +759,14 @@ impl PatternVM { _ => {} } - match self.step(program, text, lookahead_state)? { + match self.step(program, chars, lookahead_state)? { StepResult::Fail => { // Good - this path failed } StepResult::Continue(states) => { + // Fail fast on fan-out; the inner `self.step()` + // calls already charge transitions. + res.grow(states.len()).map_err(budget_to_pattern_error)?; next_states.extend(states); } StepResult::Match(_) => { @@ -600,8 +776,10 @@ impl PatternVM { } } + res.release(consumed); // the previous generation is consumed current_states = next_states; } + drop(res); // release the negative-lookahead frontier if !any_matched && current_states.is_empty() { // All paths failed - which is what we want for negative lookahead @@ -649,7 +827,6 @@ impl PatternVM { // Try matching at different positions before current position let mut matched = false; - let text_chars: Vec = text.chars().collect(); // Get the text before current position if state.pos > 0 { @@ -660,24 +837,34 @@ impl PatternVM { for start_offset in 1..=max_lookback { let start_pos = state.pos - start_offset; - // Create a new VM to execute the lookbehind pattern - let mut lookbehind_vm = PatternVM::new(); + // Create a nested VM sharing this match's meter, so + // its transitions/states count against the same + // per-match ceilings. + let mut lookbehind_vm = PatternVM::with_meter(Arc::clone(&self.meter)); - // Create a slice of text to match against - let text_slice: String = - 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(); - // Try to match the entire slice - if let Ok(result) = - lookbehind_vm.execute(lookbehind_program, &text_slice) - && result - { + // Try to match the entire slice with the non-resetting + // runners, so the shared per-match meter is not reset. + // `?` propagates a budget breach from the nested VM. + if lookbehind_vm.run_execute(lookbehind_program, &text_slice)? { // Check if the match uses the entire slice - let matches = - lookbehind_vm.find_all(lookbehind_program, &text_slice, &[]); + let matches = lookbehind_vm.run_find_all( + lookbehind_program, + &text_slice, + &[], + )?; + // `first_match.end` is a CHARACTER index (like + // every `MatchResult` offset), while + // `text_slice.len()` is a BYTE count — they + // diverge on multibyte UTF-8, inverting the + // full-slice test. The slice spans exactly + // `start_offset` characters, so compare with that. if let Some(first_match) = matches.first() && first_match.start == 0 - && first_match.end == text_slice.len() + && first_match.end == start_offset { matched = true; break; @@ -696,7 +883,6 @@ impl PatternVM { Instruction::CheckNegativeLookbehind(lookbehind_program) => { // Similar to CheckLookbehind but expects the pattern to NOT match let mut matched = false; - let text_chars: Vec = text.chars().collect(); if state.pos > 0 { // Try to match the pattern ending at current position @@ -705,24 +891,34 @@ impl PatternVM { for start_offset in 1..=max_lookback { let start_pos = state.pos - start_offset; - // Create a new VM to execute the lookbehind pattern - let mut lookbehind_vm = PatternVM::new(); + // Create a nested VM sharing this match's meter, so + // its transitions/states count against the same + // per-match ceilings. + let mut lookbehind_vm = PatternVM::with_meter(Arc::clone(&self.meter)); - // Create a slice of text to match against - let text_slice: String = - 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(); - // Try to match the entire slice - if let Ok(result) = - lookbehind_vm.execute(lookbehind_program, &text_slice) - && result - { + // Try to match the entire slice with the non-resetting + // runners, so the shared per-match meter is not reset. + // `?` propagates a budget breach from the nested VM. + if lookbehind_vm.run_execute(lookbehind_program, &text_slice)? { // Check if the match uses the entire slice - let matches = - lookbehind_vm.find_all(lookbehind_program, &text_slice, &[]); + let matches = lookbehind_vm.run_find_all( + lookbehind_program, + &text_slice, + &[], + )?; + // `first_match.end` is a CHARACTER index (like + // every `MatchResult` offset), while + // `text_slice.len()` is a BYTE count — they + // diverge on multibyte UTF-8, inverting the + // full-slice test. The slice spans exactly + // `start_offset` characters, so compare with that. if let Some(first_match) = matches.first() && first_match.start == 0 - && first_match.end == text_slice.len() + && first_match.end == start_offset { matched = true; break; @@ -831,6 +1027,52 @@ mod tests { assert!(!vm.execute(&program, "c").unwrap()); } + #[test] + fn find_uses_char_indices_across_multibyte_input() { + // The input is materialized into `Vec` once up front and matched by + // CHARACTER index. A digit after multibyte characters must be located at + // its char index (not a byte offset), and the matched text must be that + // one character — guarding the single up-front collection and the + // `chars.len()` position bound. + let mut program = Program::new(); + program.push(Instruction::CharClass(CharClassType::Digit)); + program.push(Instruction::Match); + + let mut vm = PatternVM::new(); + // "café☕7": c a f é ☕ 7 → the digit is at character index 5. + let result = vm + .find(&program, "café☕7", &[]) + .expect("the digit should be found"); + assert_eq!(result.matched_text, "7"); + assert_eq!(result.start, 5); + assert_eq!(result.end, 6); + } + + #[test] + fn cancellation_is_observed_before_materializing_large_input() { + use crate::exec::budget::ExecutionBudget; + use std::sync::Arc; + + // Pattern input is a runtime value not bounded by `max_source_size`. The + // runner checkpoints the budget *before* collecting the input, so an + // already-cancelled run aborts without paying the O(text) materialization + // (and without the old per-step re-collection). + let mut program = Program::new(); + program.push(Instruction::Char('z')); + program.push(Instruction::Match); + + let budget = Arc::new(ExecutionBudget::default()); + budget.cancel(); + let mut vm = PatternVM::with_budget(budget); + + let big = "a".repeat(1_000_000); + let result = vm.try_find(&program, &big, &[]); + assert!( + result.is_err(), + "a cancelled run must abort before matching a large input" + ); + } + #[test] fn test_anchors() { // Pattern: start of text + 'a' + end of text @@ -878,3 +1120,62 @@ mod tests { assert!(!result2); } } + +#[cfg(test)] +mod unicode_lookbehind_tests { + //! Lookbehind must keep its full-slice test entirely in CHARACTER indices. + //! The lookbehind slice is built from a `Vec`, and `MatchResult::end` + //! is a character index; comparing it against the slice's BYTE length + //! (`String::len()`) diverges the moment the window holds a multibyte char, + //! inverting the assertion. These pin both directions over a 2-byte `é`. + use crate::parser::ast::PatternExpression; + use crate::pattern::CompiledPattern; + + fn matches(pattern: &PatternExpression, text: &str) -> bool { + CompiledPattern::compile(pattern) + .expect("pattern compiles") + .matches(text) + } + + fn lit(s: &str) -> PatternExpression { + PatternExpression::Literal(s.to_string()) + } + + #[test] + fn positive_lookbehind_spans_a_multibyte_window() { + // (?<=café)! — the "café" window ends at character index 4 but byte + // index 5, so a byte-length comparison would never see a full-slice + // match and the assertion would wrongly fail. + let pattern = PatternExpression::Sequence(vec![ + PatternExpression::Lookbehind(Box::new(lit("café"))), + lit("!"), + ]); + assert!( + matches(&pattern, "café!"), + "positive lookbehind must span the multibyte 'é' window" + ); + assert!( + !matches(&pattern, "cafe!"), + "a window that does not match must not satisfy the lookbehind" + ); + } + + #[test] + fn negative_lookbehind_spans_a_multibyte_window() { + // (? Self { - let config = WflConfig::default(); - let interpreter = Interpreter::with_timeout(config.timeout_seconds); + // Honor the user's `.wflcfg` (e.g. a smaller `max_source_size` or + // `timeout_seconds`) rather than hard-coding defaults. Fall back to the + // defaults if the current directory can't be determined. + let config = std::sync::Arc::new( + std::env::current_dir() + .map(|dir| crate::config::load_config_with_global(&dir)) + .unwrap_or_default(), + ); + // One interpreter serves the whole session, but each command gets its + // own fresh budget (see `reset_command_budget`), so the wall-clock + // deadline is *per command* — a runaway command still times out, while a + // long-idle session is never penalized on its next command. The initial + // budget is replaced before the first command runs. + let budget = std::sync::Arc::new(ExecutionBudget::from_config(&config)); + let interpreter = + Interpreter::with_config_and_budget(std::sync::Arc::clone(&config), budget); ReplState { interpreter, @@ -49,6 +63,16 @@ impl ReplState { } } + /// Give the next command a fresh budget (new wall-clock deadline, cleared + /// operation/cancellation counters) while preserving the session's + /// environment. Returns a handle to the new budget so the caller can request + /// cooperative cancellation (Ctrl-C) during execution. + pub fn reset_command_budget(&mut self) -> std::sync::Arc { + let budget = std::sync::Arc::new(ExecutionBudget::from_config(self.interpreter.config())); + self.interpreter.set_budget(std::sync::Arc::clone(&budget)); + budget + } + pub async fn process_line(&mut self, line: &str) -> Result, String> { if line.trim().starts_with('.') { match self.handle_repl_command(line.trim())? { @@ -59,13 +83,40 @@ impl ReplState { } } - if !self.input_buffer.is_empty() { + // Enforce the source-size ceiling on the *prospective* buffer length — + // computed with `checked_add` — BEFORE appending, so a single pasted line + // above the cap is never copied into the buffer at all (nor re-cloned and + // re-tokenized on every following line). Only mutate the buffer once the + // new size is known to fit. + let newline = usize::from(!self.input_buffer.is_empty()); + let prospective = self + .input_buffer + .len() + .checked_add(newline) + .and_then(|n| n.checked_add(line.len())); + let within_cap = matches!( + prospective.map(|len| self.interpreter.budget().check_source_bytes(len)), + Some(Ok(())), + ); + if !within_cap { + self.input_buffer.clear(); + self.in_multiline = false; + let max = self.interpreter.budget().max_source_bytes(); + return Err(format!( + "Source too large: exceeds the configured limit ({max} bytes)" + )); + } + + if newline == 1 { self.input_buffer.push('\n'); } self.input_buffer.push_str(line); let input = self.input_buffer.clone(); - let tokens = lex_wfl_with_positions(&input); + // Lex under the command's scoped budget: a Ctrl-C cancellation (or a + // deadline breach) mid-paste surfaces as a typed error instead of a + // truncated stream that multiline detection would misread as incomplete. + let tokens = lex_wfl_with_positions_checked(&input).map_err(|e| e.message())?; if self.is_input_incomplete(&tokens) { self.in_multiline = true; @@ -132,7 +183,13 @@ impl ReplState { } async fn process_complete_input(&mut self, input: &str) -> Result, String> { - let tokens = lex_wfl_with_positions(input); + // Apply the same source-size ceiling the CLI uses, so pasting an + // oversized blob into the REPL is refused before it is lexed/parsed. + if let Err(exceeded) = self.interpreter.budget().check_source_bytes(input.len()) { + return Err(exceeded.message()); + } + + let tokens = lex_wfl_with_positions_checked(input).map_err(|e| e.message())?; let mut parser = Parser::new(&tokens); let program = match parser.parse() { @@ -200,7 +257,16 @@ impl ReplState { } let mut type_checker = TypeChecker::new(); - if let Err(errors) = type_checker.check_types(&program) { + if let Err(failure) = type_checker.check_types(&program) { + // A shared-budget breach (deadline/cancellation/resource) is fatal + // for this command and must not be rendered as an ordinary type + // diagnostic that the REPL might otherwise shrug off. + let errors = match failure { + TypeCheckError::Budget(exceeded) => { + return Ok(Some(format!("Error: {}", exceeded.message()))); + } + TypeCheckError::Types(errors) => errors, + }; let mut error_messages = Vec::new(); for error in &errors { let diagnostic = reporter.convert_type_error(file_id, error); @@ -353,7 +419,38 @@ pub async fn run_repl() -> RustylineResult<()> { Ok(line) => { rl.add_history_entry(&line)?; - match repl_state.process_line(&line).await { + // Fresh per-command budget (new deadline), and a handle so Ctrl-C + // *during execution* cancels cooperatively. Ctrl-C while waiting + // for input is still handled by rustyline (below). + let budget = repl_state.reset_command_budget(); + let outcome = { + // Scope the command's budget as the TASK-local current budget + // for the WHOLE pipeline — lexing, parsing, analysis, type + // checking, and interpretation all run inside `process_line`, + // so each front-end phase sees `ExecutionBudget::current()`. + // Task-local (not a thread-local guard held across `.await`) + // so an interleaved task can never observe this command's + // budget or restore stale state; `budget` itself is retained + // so the Ctrl-C arm can still cancel it cooperatively. + let fut = ExecutionBudget::scope( + std::sync::Arc::clone(&budget), + repl_state.process_line(&line), + ); + tokio::pin!(fut); + let mut cancelled = false; + loop { + tokio::select! { + r = &mut fut => break r, + _ = tokio::signal::ctrl_c(), if !cancelled => { + budget.cancel(); + cancelled = true; + println!("^C — cancelling current command…"); + } + } + } + }; + + match outcome { Ok(Some(output)) => println!("{output}"), Ok(None) => {} // No output needed Err(error) => println!("Error: {error}"), @@ -389,6 +486,23 @@ mod tests { assert_eq!(result.unwrap(), CommandResult::ClearedScreen); } + #[test] + fn repl_resets_a_bounded_budget_per_command() { + let mut repl = ReplState::new(); + let first = repl.reset_command_budget(); + // Each command runs under a wall-clock deadline (per-command), not the + // old disabled-deadline session budget. + assert!( + first.limits().max_duration.is_some(), + "per-command budget must carry a deadline" + ); + let second = repl.reset_command_budget(); + assert!( + !std::sync::Arc::ptr_eq(&first, &second), + "each command must get a fresh budget instance" + ); + } + #[test] #[cfg(unix)] #[ignore] // This test manipulates stdout and should be run explicitly diff --git a/src/stdlib/pattern.rs b/src/stdlib/pattern.rs index 5bcd6e7e..1196758b 100644 --- a/src/stdlib/pattern.rs +++ b/src/stdlib/pattern.rs @@ -1,11 +1,28 @@ +use crate::exec::budget::ExecutionBudget; use crate::interpreter::environment::Environment; -use crate::interpreter::error::RuntimeError; +use crate::interpreter::error::{ErrorKind, RuntimeError}; use crate::interpreter::value::Value; +use crate::pattern::PatternError; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use std::sync::Arc; +/// Map a pattern-VM error to a `RuntimeError`. A budget breach (ReDoS +/// step/state ceiling or cancellation) becomes a catchable `ResourceLimit` +/// error rather than a silent empty result; other pattern errors are general. +fn pattern_err(err: PatternError) -> RuntimeError { + let kind = match err { + // A pattern that outruns the wall-clock deadline is a timeout. + PatternError::Timeout { .. } => ErrorKind::Timeout, + PatternError::StepLimitExceeded + | PatternError::StateLimitExceeded + | PatternError::Cancelled => ErrorKind::ResourceLimit, + _ => ErrorKind::General, + }; + RuntimeError::with_kind(err.to_string(), 0, 0, kind) +} + pub fn register(env: &mut Environment) { // Register new pattern functions that work with our pattern system env.define_native("pattern_matches", pattern_matches_native); @@ -49,7 +66,10 @@ pub fn pattern_matches_native(args: Vec) -> Result { } }; - let matches = compiled_pattern.matches(text_str); + let budget = ExecutionBudget::current_or_default(); + let matches = compiled_pattern + .matches_with_budget(text_str, &budget) + .map_err(pattern_err)?; Ok(Value::Bool(matches)) } @@ -86,7 +106,11 @@ pub fn pattern_find_native(args: Vec) -> Result { } }; - match compiled_pattern.find(text_str) { + let budget = ExecutionBudget::current_or_default(); + match compiled_pattern + .find_with_budget(text_str, &budget) + .map_err(pattern_err)? + { Some(match_result) => { let mut result_map = HashMap::new(); result_map.insert( @@ -150,7 +174,10 @@ pub fn pattern_find_all_native(args: Vec) -> Result } }; - let matches = compiled_pattern.find_all(text_str); + let budget = ExecutionBudget::current_or_default(); + let matches = compiled_pattern + .find_all_with_budget(text_str, &budget) + .map_err(pattern_err)?; let mut result_list = Vec::new(); for match_result in matches { @@ -271,7 +298,10 @@ pub fn native_pattern_split( }; // Find all matches of the pattern in the text - let matches = pattern.find_all(text); + let budget = ExecutionBudget::current_or_default(); + let matches = pattern + .find_all_with_budget(text, &budget) + .map_err(pattern_err)?; // If no matches, return the entire text as a single element if matches.is_empty() { diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 8dce9bf7..81be1678 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -49,6 +49,47 @@ impl fmt::Display for TypeError { } } +/// The outcome of [`TypeChecker::check_types`] when it does not succeed. +/// +/// A shared-budget breach (deadline, cancellation, operation/depth/byte +/// ceiling) is a **fatal** event: the run must stop, and it must never be +/// mistaken for — or silently downgraded to — an ordinary type diagnostic. +/// Encoding it as a distinct variant forces every caller to distinguish the two +/// at the type level, instead of relying on an optional side channel a caller +/// can forget to consult. This is why `check_types` returns this enum rather +/// than a bare `Vec`. +#[derive(Debug, Clone)] +pub enum TypeCheckError { + /// The shared run budget was exhausted during analysis or type checking. + /// Fatal — callers must abort the run (do not execute the program). + Budget(crate::exec::budget::BudgetExceeded), + /// Ordinary type diagnostics. Callers may report these and, in the + /// `include from` path, continue (matching the main-file pipeline). + Types(Vec), +} + +impl TypeCheckError { + /// Render this failure as type diagnostics: the diagnostics themselves, or a + /// budget breach rendered as a single diagnostic. Convenience for callers + /// (and tests) that only need to display the failure. + pub fn into_diagnostics(self) -> Vec { + match self { + TypeCheckError::Types(errors) => errors, + TypeCheckError::Budget(breach) => { + vec![TypeError::new(breach.message(), None, None, 0, 0)] + } + } + } + + /// The budget breach, if this failure was one. + pub fn budget_breach(&self) -> Option<&crate::exec::budget::BudgetExceeded> { + match self { + TypeCheckError::Budget(breach) => Some(breach), + TypeCheckError::Types(_) => None, + } + } +} + impl std::error::Error for TypeError {} impl fmt::Display for Type { @@ -96,6 +137,14 @@ pub struct TypeChecker { /// expose their actions dynamically at runtime, so undefined-action errors /// are suppressed to match the analyzer (see issue #548). has_includes: bool, + /// A shared-budget breach hit during type checking. Kept **separate** from + /// `errors` because callers (the CLI, `include`) print `TypeError`s as + /// non-fatal warnings and continue — which would erase a real + /// deadline/cancellation/resource breach. This is an internal latch: + /// `check_types` surfaces it to callers as the fatal + /// [`TypeCheckError::Budget`] variant so the distinction is enforced by the + /// type system rather than an optional side channel. + budget_error: Option, } impl Default for TypeChecker { @@ -116,6 +165,7 @@ impl TypeChecker { analyzer_already_run: false, current_container: None, has_includes: false, + budget_error: None, } } @@ -128,6 +178,7 @@ impl TypeChecker { analyzer_already_run: true, // Analyzer has already been run when passed in current_container: None, has_includes: false, + budget_error: None, } } @@ -247,7 +298,13 @@ impl TypeChecker { } } - pub fn check_types(&mut self, program: &Program) -> Result<(), Vec> { + pub fn check_types(&mut self, program: &Program) -> Result<(), TypeCheckError> { + // Reset the per-run budget breach so a reused TypeChecker (e.g. an editor + // session) neither carries a stale breach nor lets the recursive + // `check_statement_types` short-circuit fire against a previous run's + // state. + self.budget_error = None; + // Detect includes: their exposed actions are only known at runtime. // Assign directly so a reused TypeChecker (e.g. an editor session) does // not carry a stale flag from a program that used includes. @@ -259,6 +316,12 @@ impl TypeChecker { if !self.analyzer_already_run && let Err(semantic_errors) = self.analyzer.analyze(program) { + // Propagate the analyzer's *typed* breach so an analysis-phase + // deadline/cancellation/resource failure stays fatal and is never + // mistaken for an ordinary semantic diagnostic. + if let Some(breach) = self.analyzer.take_budget_error() { + return Err(TypeCheckError::Budget(breach)); + } for error in semantic_errors { self.errors.push(TypeError::new( error.message, @@ -268,17 +331,30 @@ impl TypeChecker { error.column, )); } - return Err(self.errors.clone()); + return Err(TypeCheckError::Types(self.errors.clone())); } for statement in &program.statements { + // The budget is polled inside `check_statement_types` (below), which + // runs for every top-level statement and recurses into nested bodies. + // Stop iterating the moment a breach is recorded there — including one + // surfaced deep inside a previous statement's nested body. + if self.budget_error.is_some() { + break; + } self.check_statement_types(statement); } + // A budget breach is fatal and takes precedence over any diagnostics + // accumulated alongside it. + if let Some(breach) = self.budget_error.take() { + return Err(TypeCheckError::Budget(breach)); + } + if self.errors.is_empty() { Ok(()) } else { - Err(self.errors.clone()) + Err(TypeCheckError::Types(self.errors.clone())) } } @@ -370,6 +446,27 @@ impl TypeChecker { } fn check_statement_types(&mut self, statement: &Statement) { + // Recursive front-end checkpoint. This method recurses into `if`/loop/ + // `try`/action/container-method bodies, so polling the run budget here + // (mirroring the parser's per-`parse_statement` placement) keeps deeply + // nested type-checking cooperative with the deadline/cancellation/ + // operation limits — not just the top-level statement boundary. Once a + // breach is recorded, short-circuit so it is captured a single time + // rather than re-charged per nested node. The breach is kept on the + // dedicated `budget_error` channel (callers must stop) AND pushed as a + // diagnostic so `Err` still short-circuits normal reporting. + if self.budget_error.is_some() { + return; + } + if let Some(budget) = crate::exec::budget::ExecutionBudget::current() + && let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) + { + self.errors + .push(TypeError::new(exceeded.message(), None, None, 0, 0)); + self.budget_error = Some(exceeded); + return; + } + match statement { Statement::PushStatement { list, @@ -2515,6 +2612,23 @@ impl TypeChecker { } fn infer_expression_type(&mut self, expression: &Expression) -> Type { + // Recursive front-end checkpoint for expressions (mirrors the analyzer's + // `analyze_expression`): `check_statement_types` polls per statement, but + // one statement can hold an arbitrarily large expression tree, so poll + // here too. The `budget_error` latch records the breach once and + // short-circuits; the returned `Any` is irrelevant because `check_types` + // turns the latched breach into the fatal `TypeCheckError::Budget`. + if self.budget_error.is_some() { + return Type::Any; + } + if let Some(budget) = crate::exec::budget::ExecutionBudget::current() + && let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) + { + self.errors + .push(TypeError::new(exceeded.message(), None, None, 0, 0)); + self.budget_error = Some(exceeded); + return Type::Any; + } match expression { Expression::Literal(literal, _, _) => match literal { Literal::String(_) => Type::Text, @@ -4235,7 +4349,7 @@ mod tests { let result = type_checker.check_types(&program); assert!(result.is_err(), "Expected type error for mismatched types"); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors .iter() @@ -4274,7 +4388,7 @@ mod tests { result.is_err(), "Expected a type error for non-map response headers" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors .iter() @@ -4343,7 +4457,7 @@ mod tests { "Expected type error for incompatible operation" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!(errors.iter().any(|e| e.message.contains("Cannot perform"))); } @@ -4392,7 +4506,7 @@ mod tests { "Expected type error for wrong argument type" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!(errors.iter().any(|e| e.message.contains("incorrect type"))); } @@ -4415,7 +4529,7 @@ mod tests { "Expected type error for non-boolean condition" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors .iter() @@ -4478,7 +4592,7 @@ mod tests { "Expected type error for incompatible operation on loop variable" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors.iter().any(|e| e.message.contains("Cannot perform")), "Expected error about invalid operation, got: {:?}", @@ -4541,7 +4655,7 @@ mod tests { result.is_err(), "Expected type error for unhandleable signal name (SIGKILL)" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors .iter() @@ -4561,7 +4675,7 @@ mod tests { let mut type_checker = TypeChecker::new(); let result = type_checker.check_types(&undefined_handler); assert!(result.is_err(), "Expected type error for undefined handler"); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors .iter() @@ -4593,7 +4707,7 @@ mod tests { result.is_err(), "Expected type error when handler is not a function" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!(errors.iter().any(|e| e.message.contains("not a function"))); // Test case 5: Handler has too many parameters @@ -4637,7 +4751,7 @@ mod tests { result.is_err(), "Expected type error when handler has too many parameters" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors .iter() @@ -4676,7 +4790,7 @@ mod tests { result.is_err(), "Expected type error when handler has wrong parameter type" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!(errors.iter().any(|e| { e.message .contains("Signal handler parameter must be a Number") @@ -4748,7 +4862,7 @@ mod tests { result.is_err(), "Expected type error for invalid server expression" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors .iter() @@ -4781,7 +4895,7 @@ mod tests { result.is_err(), "Expected type error for invalid timeout expression" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors .iter() @@ -4832,7 +4946,7 @@ mod tests { result.is_err(), "Expected type error for invalid list reference in pattern" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!(errors.iter().any(|e| e.message.contains("must be a List"))); // Test valid List reference @@ -4888,7 +5002,7 @@ mod tests { result.is_err(), "Expected type error for List reference in pattern" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors .iter() @@ -5005,7 +5119,7 @@ mod tests { result.is_err(), "Action returning Number used as a file path must still be flagged" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors.iter().any(|e| e.found == Some(Type::Number)), "Mismatch should report the inferred Number type, got: {errors:?}" @@ -5127,7 +5241,7 @@ mod tests { result.is_err(), "Reachable Number return used as a file path must still be flagged" ); - let errors = result.err().unwrap(); + let errors = result.err().unwrap().into_diagnostics(); assert!( errors.iter().any(|e| e.found == Some(Type::Number)), "Inferred type should be precisely Number (not widened to Any), got: {errors:?}" diff --git a/src/wfl_config/checker.rs b/src/wfl_config/checker.rs index 611244f9..69c072df 100644 --- a/src/wfl_config/checker.rs +++ b/src/wfl_config/checker.rs @@ -504,6 +504,112 @@ impl ConfigChecker { }, ); + // Shared ExecutionBudget limits (see src/exec/budget.rs). Registered so + // `--configCheck` accepts them and `--configFix` does not strip them + // (which for `max_operations` would silently revert a ceiling to + // unlimited). Includes `web_server_request_queue_bound`, which predates + // this change but was never registered. + { + let mut int_setting = |name: &str, default: &str, category: &str, desc: &str| { + expected_settings.insert( + name.to_string(), + ExpectedSetting { + name: name.to_string(), + config_type: ConfigType::Integer, + required: false, + default_value: Some(default.to_string()), + description: desc.to_string(), + valid_values: None, + category: category.to_string(), + }, + ); + }; + int_setting( + "web_server_request_queue_bound", + "256", + "Web Server", + "Max queued HTTP requests before shedding with 503 (min 1)", + ); + int_setting( + "web_server_max_response_size", + "67108864", + "Web Server", + "Maximum HTTP response body size in bytes (default 64 MiB, min 1)", + ); + int_setting( + "web_server_response_timeout_seconds", + "300", + "Web Server", + "Seconds to await a handler before shedding with 504; 0 = disabled", + ); + int_setting( + "web_socket_queue_bound", + "1024", + "Web Server", + "Max queued frames/events per WebSocket channel (min 1)", + ); + int_setting( + "web_socket_max_connections", + "1024", + "Web Server", + "Max simultaneous live WebSocket connections (min 1)", + ); + int_setting( + "web_socket_max_message_size", + "1048576", + "Web Server", + "Max size in bytes of a single WebSocket text message (default 1 MiB, min 1)", + ); + int_setting( + "web_socket_max_queued_bytes", + "16777216", + "Web Server", + "Global ceiling in bytes on queued WebSocket payloads (default 16 MiB, min 1)", + ); + int_setting( + "max_operations", + "0", + "Execution Budget", + "Hard ceiling on interpreter operations; 0 = unlimited", + ); + int_setting( + "max_call_depth", + "1000", + "Execution Budget", + "Maximum WFL call/recursion depth (min 1)", + ); + int_setting( + "max_import_depth", + "64", + "Execution Budget", + "Maximum nested load module / include depth (min 1)", + ); + int_setting( + "max_execute_file_depth", + "4", + "Execution Budget", + "Maximum execute file nesting depth (min 1)", + ); + int_setting( + "max_pattern_steps", + "5000000", + "Execution Budget", + "Maximum pattern-VM transitions per match (ReDoS guard, min 1)", + ); + int_setting( + "max_pattern_states", + "10000", + "Execution Budget", + "Maximum simultaneously-active pattern states per match (min 1)", + ); + int_setting( + "max_source_size", + "67108864", + "Execution Budget", + "Maximum WFL source-file size in bytes (default 64 MiB, min 1)", + ); + } + Self { expected_settings } } @@ -521,6 +627,9 @@ impl ConfigChecker { "Security", "Subprocess Management", "Web Server", + // Without this, the config wizard silently omitted every setting + // assigned to the ExecutionBudget category. + "Execution Budget", ]; categories @@ -607,21 +716,53 @@ impl ConfigChecker { match setting.config_type { ConfigType::Integer => { - if value.parse::().is_err() { - issues.push(ConfigIssue { - file_path: file_path.to_path_buf(), - kind: ConfigIssueKind::InvalidType, - issue_type: ConfigIssueType::Error, - message: format!( - "Invalid type for {key}: expected integer, got '{value}'" - ), - setting_name: Some(key.to_string()), - line_number: Some(line_number + 1), - fix_message: setting - .default_value - .as_ref() - .map(|default| format!("Set to default value: {default}")), - }); + // Validate exactly as the loader does: a *non-negative* + // integer parsed as `u64` (the loader's `usize`/`u64` + // domain), not `i64`. The old `i64` parse wrongly accepted + // negatives and rejected valid `u64` values above + // `i64::MAX`, so `--configFix` could "fix" a good value to + // 0/unlimited and let `max_operations = -1` pass. + match value.parse::() { + Err(_) => { + issues.push(ConfigIssue { + file_path: file_path.to_path_buf(), + kind: ConfigIssueKind::InvalidType, + issue_type: ConfigIssueType::Error, + message: format!( + "Invalid type for {key}: expected a non-negative integer, got '{value}'" + ), + setting_name: Some(key.to_string()), + line_number: Some(line_number + 1), + fix_message: setting + .default_value + .as_ref() + .map(|default| format!("Set to default value: {default}")), + }); + } + Ok(n) => { + // Enforce the loader's per-key minimum (most + // budget/web keys require >= 1 via + // `set_positive_usize`), so a value the loader + // would reject and silently keep-default is + // reported here instead of appearing valid. + if let Some(min) = integer_min_for_key(key) + && n < min + { + issues.push(ConfigIssue { + file_path: file_path.to_path_buf(), + kind: ConfigIssueKind::InvalidValue, + issue_type: ConfigIssueType::Error, + message: format!( + "Invalid value for {key}: must be at least {min}, got '{value}'" + ), + setting_name: Some(key.to_string()), + line_number: Some(line_number + 1), + fix_message: setting.default_value.as_ref().map(|default| { + format!("Set to default value: {default}") + }), + }); + } + } } } ConfigType::Boolean => { @@ -946,6 +1087,33 @@ fn is_valid_ip_address(addr: &str) -> bool { addr.parse::().is_ok() } +/// The minimum a given integer config key accepts, matching the loader's +/// per-key validation in `src/config.rs`. `None` leaves a pre-existing key with +/// no minimum (any non-negative integer). The budget/web keys mirror the loader: +/// `max_operations` and `web_server_response_timeout_seconds` accept `0` +/// (unlimited/disabled), while every other budget/web ceiling requires `>= 1` +/// (the loader's `set_positive_usize`). +fn integer_min_for_key(key: &str) -> Option { + match key { + "max_operations" | "web_server_response_timeout_seconds" => Some(0), + "timeout_seconds" + | "web_server_max_body_size" + | "web_server_request_queue_bound" + | "web_server_max_response_size" + | "web_socket_queue_bound" + | "web_socket_max_connections" + | "web_socket_max_message_size" + | "web_socket_max_queued_bytes" + | "max_call_depth" + | "max_import_depth" + | "max_execute_file_depth" + | "max_pattern_steps" + | "max_pattern_states" + | "max_source_size" => Some(1), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -982,6 +1150,128 @@ max_line_length = 80 assert_eq!(issues[0].kind, ConfigIssueKind::MissingFile); } + #[test] + fn test_budget_keys_are_known_and_survive_fix() { + // Every ExecutionBudget key must be recognized by --configCheck and left + // intact by --configFix (stripping `max_operations` would silently revert + // a configured ceiling to unlimited). + let keys = [ + "max_operations", + "max_call_depth", + "max_import_depth", + "max_execute_file_depth", + "max_pattern_steps", + "max_pattern_states", + "max_source_size", + "web_server_max_response_size", + "web_server_response_timeout_seconds", + "web_server_request_queue_bound", + "web_socket_queue_bound", + "web_socket_max_connections", + "web_socket_max_message_size", + "web_socket_max_queued_bytes", + ]; + let checker = ConfigChecker::new(); + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join(".wflcfg"); + let config_content = "\ +max_operations = 25000 +max_call_depth = 2000 +max_import_depth = 32 +max_execute_file_depth = 6 +max_pattern_steps = 250000 +max_pattern_states = 5000 +max_source_size = 1048576 +web_server_max_response_size = 5242880 +web_server_response_timeout_seconds = 30 +web_server_request_queue_bound = 512 +web_socket_queue_bound = 2048 +web_socket_max_connections = 256 +web_socket_max_message_size = 2097152 +web_socket_max_queued_bytes = 33554432 +"; + fs::write(&config_path, config_content).unwrap(); + + let issues = checker.check_config_file(&config_path).unwrap(); + assert!(issues.is_empty(), "Expected no issues, got: {issues:?}"); + + let after = checker.fix_config_file(&config_path).unwrap(); + assert!( + after.is_empty(), + "Expected no issues after fix, got: {after:?}" + ); + let content = fs::read_to_string(&config_path).unwrap(); + for key in keys { + assert!( + content.contains(&format!("{key} =")), + "--configFix stripped '{key}':\n{content}" + ); + } + } + + #[test] + fn test_budget_keys_enforce_loader_ranges() { + // The checker must reject exactly what the loader rejects: a negative + // `max_operations` (loader keeps it unlimited) and a zero `max_call_depth` + // (a positive-only key), rather than letting them pass check/fix. + let checker = ConfigChecker::new(); + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join(".wflcfg"); + fs::write( + &config_path, + "max_operations = -1\nmax_call_depth = 0\nmax_source_size = 4096\n", + ) + .unwrap(); + + let issues = checker.check_config_file(&config_path).unwrap(); + let bad: std::collections::HashSet<_> = issues + .iter() + .filter_map(|i| i.setting_name.as_deref()) + .collect(); + assert!( + bad.contains("max_operations"), + "negative max_operations must be rejected; issues: {issues:?}" + ); + assert!( + bad.contains("max_call_depth"), + "zero max_call_depth must be rejected; issues: {issues:?}" + ); + assert!( + !bad.contains("max_source_size"), + "a valid max_source_size must not be flagged; issues: {issues:?}" + ); + + // The pre-existing positive-only keys the loader clamps/rejects at 0 are + // now enforced by the checker too. + fs::write( + &config_path, + "timeout_seconds = 0\nweb_server_max_body_size = 0\n", + ) + .unwrap(); + let issues = checker.check_config_file(&config_path).unwrap(); + let bad: std::collections::HashSet<_> = issues + .iter() + .filter_map(|i| i.setting_name.as_deref()) + .collect(); + assert!( + bad.contains("timeout_seconds") && bad.contains("web_server_max_body_size"), + "zero timeout_seconds / web_server_max_body_size must be rejected; issues: {issues:?}" + ); + + // 0 is valid for max_operations (unlimited); a large u64 above i64::MAX + // must be accepted, not rejected as before. + fs::write( + &config_path, + "max_operations = 0\nmax_pattern_steps = 18446744073709551615\n", + ) + .unwrap(); + let issues = checker.check_config_file(&config_path).unwrap(); + assert!( + issues.is_empty(), + "0 max_operations and a large u64 must be accepted; got: {issues:?}" + ); + } + #[test] fn test_check_invalid_type() { let checker = ConfigChecker::new(); diff --git a/tests/execution_budget_test.rs b/tests/execution_budget_test.rs new file mode 100644 index 00000000..0e34979b --- /dev/null +++ b/tests/execution_budget_test.rs @@ -0,0 +1,915 @@ +//! Integration tests for the shared `ExecutionBudget` (see `src/exec/budget.rs`). +//! +//! Two layers: +//! 1. The new `.wflcfg` budget keys parse into `WflConfig` (default, override, +//! zero/garbage rejection) via the public `load_config` API. +//! 2. End-to-end: running a WFL program actually enforces the budget — the +//! recursion ceiling turns runaway recursion into a clean, catchable error +//! instead of a native stack overflow, and the source-size ceiling refuses +//! an oversized program before it runs. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use wfl::config::load_config; + +mod test_helpers; + +/// Render a path for embedding in a WFL string literal. WFL treats `\` as an +/// escape character, so Windows paths must use forward slashes (which the +/// runtime accepts on every platform). +fn wfl_path(p: &std::path::Path) -> String { + p.display().to_string().replace('\\', "/") +} + +/// Write a `.wflcfg` with the given body into a fresh temp dir and load it. +fn load_with_cfg(body: &str) -> wfl::config::WflConfig { + let dir = tempfile::tempdir().expect("create temp dir"); + fs::write(dir.path().join(".wflcfg"), body).expect("write .wflcfg"); + load_config(dir.path()) +} + +/// Run a WFL `program` in a fresh temp dir alongside an optional `.wflcfg`, so a +/// budget knob can be exercised end-to-end without disturbing the shared temp +/// dir other integration tests use. Returns the process output. +fn run_with_cfg(cfg: Option<&str>, program: &str) -> std::process::Output { + let binary = test_helpers::get_wfl_binary_path(); + let dir = tempfile::tempdir().expect("create temp dir"); + if let Some(cfg) = cfg { + fs::write(dir.path().join(".wflcfg"), cfg).expect("write .wflcfg"); + } + let script: PathBuf = dir.path().join("program.wfl"); + fs::write(&script, program).expect("write program"); + Command::new(binary) + .arg(&script) + .output() + .expect("run wfl binary") +} + +// --- config parsing -------------------------------------------------------- + +#[test] +fn budget_keys_use_documented_defaults() { + let cfg = load_with_cfg("# empty\n"); + assert_eq!(cfg.max_operations, None); + assert_eq!(cfg.max_call_depth, 1_000); + assert_eq!(cfg.max_import_depth, 64); + assert_eq!(cfg.max_execute_file_depth, 4); + assert_eq!(cfg.max_pattern_steps, 5_000_000); + assert_eq!(cfg.max_pattern_states, 10_000); + assert_eq!(cfg.max_source_size, 64 * 1024 * 1024); + assert_eq!(cfg.web_server_max_response_size, 64 * 1024 * 1024); + assert_eq!(cfg.web_socket_queue_bound, 1_024); + assert_eq!(cfg.web_socket_max_connections, 1_024); +} + +#[test] +fn budget_keys_accept_overrides() { + let cfg = load_with_cfg( + "max_call_depth = 250\n\ + max_import_depth = 8\n\ + max_execute_file_depth = 2\n\ + max_pattern_steps = 5000\n\ + max_pattern_states = 500\n\ + max_source_size = 4096\n\ + web_server_max_response_size = 2048\n\ + web_socket_queue_bound = 32\n\ + web_socket_max_connections = 16\n", + ); + assert_eq!(cfg.max_call_depth, 250); + assert_eq!(cfg.max_import_depth, 8); + assert_eq!(cfg.max_execute_file_depth, 2); + assert_eq!(cfg.max_pattern_steps, 5000); + assert_eq!(cfg.max_pattern_states, 500); + assert_eq!(cfg.max_source_size, 4096); + assert_eq!(cfg.web_server_max_response_size, 2048); + assert_eq!(cfg.web_socket_queue_bound, 32); + assert_eq!(cfg.web_socket_max_connections, 16); +} + +#[test] +fn max_operations_zero_means_unlimited() { + // 0 is the documented "no ceiling" sentinel, not an invalid value. + let cfg = load_with_cfg("max_operations = 0\n"); + assert_eq!(cfg.max_operations, None); + let cfg = load_with_cfg("max_operations = 25000\n"); + assert_eq!(cfg.max_operations, Some(25_000)); +} + +#[test] +fn zero_and_garbage_budget_values_keep_defaults() { + // The positive-integer keys reject 0 and non-numeric input, keeping defaults. + let cfg = load_with_cfg("max_call_depth = 0\nmax_pattern_states = nope\n"); + assert_eq!(cfg.max_call_depth, 1_000); + assert_eq!(cfg.max_pattern_states, 10_000); +} + +// --- end-to-end enforcement ------------------------------------------------ + +const RECURSE_PROGRAM: &str = "\ +define action called recurse with parameters n: + check if n is greater than 0: + return recurse of (n minus 1) + end check + return 0 +end action +display recurse of 100000 +"; + +#[test] +fn deep_recursion_is_a_clean_error_not_a_stack_overflow() { + // With the default ceiling (1000) and the interpreter's large stack, runaway + // recursion must surface as a catchable runtime error, never crash the + // process with a native stack overflow. + let output = run_with_cfg(None, RECURSE_PROGRAM); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("Maximum call depth (1000)"), + "expected the call-depth ceiling to fire; got:\n{combined}" + ); + assert!( + !combined.contains("stack overflow"), + "recursion must not reach a native stack overflow; got:\n{combined}" + ); +} + +#[test] +fn configured_call_depth_is_honored() { + // A low ceiling fires early and reports the configured value. + let output = run_with_cfg(Some("max_call_depth = 12\n"), RECURSE_PROGRAM); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("Maximum call depth (12)"), + "expected the configured ceiling of 12; got:\n{combined}" + ); +} + +#[test] +fn oversized_source_is_refused() { + // A generous program under a tiny source ceiling is refused before running. + let program = format!("display \"{}\"\n", "x".repeat(200)); + let output = run_with_cfg(Some("max_source_size = 50\n"), &program); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("Source file too large"), + "expected the source-size ceiling to fire; got:\n{combined}" + ); + assert!( + !output.status.success(), + "an oversized source must exit non-zero" + ); +} + +#[test] +fn catching_a_recursion_limit_leaves_a_consistent_interpreter() { + // A caught call-depth ResourceLimit must not corrupt the interpreter: the + // enclosing `count` loop keeps running, its `count` variable stays readable, + // and re-recursing after the catch stays bounded (no native stack overflow, + // no depth under-count). Guards the dedicated call_depth counter and the + // "don't mutate state in budget_error" contract. + let program = "\ +define action called deep with parameters n: + return deep of (n plus 1) +end action + +store caught as 0 +count from 1 to 3: + try: + store dummy as deep of 0 + catch: + change caught to caught plus 1 + display \"iteration \" with count + end try +end count +display \"caught \" with caught with \" times\" +"; + let output = run_with_cfg(Some("max_call_depth = 20\n"), program); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("caught 3 times"), + "the count loop must survive 3 caught recursion errors; got:\n{combined}" + ); + // `count` stays readable inside the loop after a caught error. + assert!( + combined.contains("iteration 1") && combined.contains("iteration 3"), + "the count variable must remain valid after a caught error; got:\n{combined}" + ); + assert!( + !combined.contains("stack overflow"), + "catch-and-recurse must stay bounded; got:\n{combined}" + ); + assert!( + output.status.success(), + "an all-caught program must exit zero; got:\n{combined}" + ); +} + +const PATTERN_PROGRAM: &str = "\ +create pattern digits: + one or more digit +end pattern +check if \"123456789\" matches digits: + display \"MATCHED\" +otherwise: + display \"NO-MATCH\" +end check +"; + +#[test] +fn pattern_step_limit_is_enforced_and_propagated() { + // A configured low pattern-step ceiling must surface as a catchable error at + // the interpreter's `matches` operator — NOT be swallowed into a non-match. + let output = run_with_cfg(Some("max_pattern_steps = 3\n"), PATTERN_PROGRAM); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("step limit"), + "a low pattern-step ceiling must trip; got:\n{combined}" + ); + assert!( + !combined.contains("NO-MATCH"), + "a budget breach must not be reported as a non-match; got:\n{combined}" + ); +} + +#[test] +fn pattern_step_limit_is_catchable() { + // The propagated pattern budget error is a ResourceLimit, catchable by a + // general `try`/`when`. + let program = "\ +create pattern digits: + one or more digit +end pattern +try: + check if \"123456789\" matches digits: + display \"MATCHED\" + end check +catch: + display \"CAUGHT\" +end try +"; + let output = run_with_cfg(Some("max_pattern_steps = 3\n"), program); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("CAUGHT"), + "a pattern budget breach must be catchable; got:\n{combined}" + ); +} + +#[test] +fn patterns_run_normally_under_default_budget() { + // The raised per-instruction default must not trip on an ordinary match. + let output = run_with_cfg(None, PATTERN_PROGRAM); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("MATCHED"), + "an ordinary pattern must match under the default budget; got:\n{stdout}{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn program_within_budget_still_runs() { + // A shallow program under default limits runs normally (no false positives). + let program = "\ +define action called recurse with parameters n: + check if n is greater than 0: + return recurse of (n minus 1) + end check + return 42 +end action +display recurse of 100 +"; + let output = run_with_cfg(None, program); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("42"), + "shallow recursion should complete; got:\n{stdout}{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn nested_execute_file_source_is_size_checked() { + // The source-size ceiling must cover nested sources, not only the top-level + // file: a small main file that `execute file`s an oversized source is + // refused when the nested file trips the cap. + let dir = tempfile::tempdir().expect("create temp dir"); + // main.wfl fits under the cap; big.wfl does not. + fs::write(dir.path().join(".wflcfg"), "max_source_size = 400\n").expect("cfg"); + let big = dir.path().join("big.wfl"); + fs::write(&big, format!("// {}\ndisplay \"hi\"\n", "x".repeat(500))).expect("big"); + let main = dir.path().join("program.wfl"); + fs::write( + &main, + format!( + "execute file at \"{}\" and read output as out\n", + wfl_path(&big) + ), + ) + .expect("main"); + + let output = Command::new(test_helpers::get_wfl_binary_path()) + .arg(&main) + .output() + .expect("run wfl"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("Source file too large"), + "nested execute-file source must be size-checked; got:\n{combined}" + ); +} + +#[test] +fn execute_file_shares_the_parent_operation_budget() { + // The child interpreter created for `execute file` must share the parent's + // budget, so work cannot be split across executed files to evade the + // operation ceiling. + // + // Two runs pin the behavior without depending on an exact op count: a + // ~50-iteration loop costs on the order of ~125 operations, so under a + // 200-op ceiling the loop *alone* passes, but the loop plus an executed + // child that runs the same loop (~250 ops total) must fail — which only + // happens if the child shares the parent's budget rather than resetting it. + let dir = tempfile::tempdir().expect("create temp dir"); + fs::write(dir.path().join(".wflcfg"), "max_operations = 200\n").expect("cfg"); + let loop_body = + "store total as 0\ncount from 1 to 50:\n change total to total plus 1\nend count\n"; + + let child = dir.path().join("child.wfl"); + fs::write(&child, format!("{loop_body}display total\n")).expect("child"); + + let run = |name: &str, body: String| -> String { + let path = dir.path().join(name); + fs::write(&path, body).expect("write program"); + let output = Command::new(test_helpers::get_wfl_binary_path()) + .arg(&path) + .output() + .expect("run wfl"); + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }; + + // Anchor: the loop alone stays under the 200-op ceiling. + let alone = run("alone.wfl", format!("{loop_body}display total\n")); + assert!( + !alone.contains("operation budget"), + "the loop alone should stay under the ceiling; got:\n{alone}" + ); + + // The loop plus the executed child crosses the shared ceiling. + let combined = run( + "program.wfl", + format!( + "{loop_body}execute file at \"{}\" and read output as out\ndisplay out\n", + wfl_path(&child) + ), + ); + assert!( + combined.contains("operation budget"), + "the operation ceiling must span parent + executed child; got:\n{combined}" + ); +} + +#[test] +fn execute_file_shares_the_parent_recursion_depth() { + // Recursion accounting must span the `execute file` boundary: a child cannot + // get a fresh full call-depth allowance, or nested execute files would + // multiply the native stack and overflow before the guard fires. + // + // With `max_call_depth = 20`, a parent recursed ~13 deep that then executes a + // child recursing ~13 deep exceeds the shared ceiling (only if the child + // inherits the parent's live depth); the child's own recursion (13 < 20) + // would otherwise pass on a reset allowance. + let dir = tempfile::tempdir().expect("create temp dir"); + fs::write(dir.path().join(".wflcfg"), "max_call_depth = 20\n").expect("cfg"); + + let child = dir.path().join("child.wfl"); + fs::write( + &child, + "define action called c with parameters n:\n\ + \x20 check if n is greater than 0:\n\ + \x20 return c of (n minus 1)\n\ + \x20 end check\n\ + \x20 return 0\n\ + end action\n\ + display c of 12\n\ + display \"child-done\"\n", + ) + .expect("child"); + + let parent = dir.path().join("program.wfl"); + fs::write( + &parent, + format!( + "define action called p with parameters n:\n\ + \x20 check if n is greater than 0:\n\ + \x20 return p of (n minus 1)\n\ + \x20 end check\n\ + \x20 execute file at \"{}\" and read output as out\n\ + \x20 display out\n\ + \x20 return 0\n\ + end action\n\ + display p of 12\n", + wfl_path(&child) + ), + ) + .expect("parent"); + + let output = Command::new(test_helpers::get_wfl_binary_path()) + .arg(&parent) + .output() + .expect("run wfl"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("Maximum call depth (20)"), + "the recursion ceiling must span parent + executed child; got:\n{combined}" + ); + assert!( + !combined.contains("child-done"), + "the child must not get a fresh depth allowance; got:\n{combined}" + ); +} + +#[test] +fn analyze_mode_consults_the_budget_in_the_front_end() { + // `--analyze` never interprets, so an operation-budget breach it surfaces can + // only come from the front end (lex/parse/analyze) actually consulting the + // shared budget — proving the phases poll it, not merely measure elapsed time + // for later interpretation. + let program = "display 1\n".repeat(40); + let dir = tempfile::tempdir().expect("create temp dir"); + fs::write(dir.path().join(".wflcfg"), "max_operations = 3\n").expect("cfg"); + let script = dir.path().join("program.wfl"); + fs::write(&script, &program).expect("program"); + + let output = Command::new(test_helpers::get_wfl_binary_path()) + .arg("--analyze") + .arg(&script) + .output() + .expect("run wfl --analyze"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("operation budget"), + "--analyze must consult the shared budget during the front end; got:\n{combined}" + ); +} + +/// Build a program whose single top-level statement (a `count` loop) holds many +/// nested statements, and parse it. Parsing happens BEFORE any budget is +/// installed so the parser's own checkpoint cannot consume the operation cap — +/// a later breach must therefore come from the phase under test recursing into +/// the nested body. +fn parse_deeply_nested_program() -> wfl::parser::ast::Program { + let mut body = String::from("count from 1 to 3:\n"); + for _ in 0..64 { + body.push_str(" display 1\n"); + } + body.push_str("end count\n"); + + let tokens = wfl::lexer::lex_wfl_with_positions(&body); + wfl::parser::Parser::new(&tokens) + .parse() + .expect("nested program parses") +} + +/// Install a run budget with a small operation cap for the lifetime of the +/// returned guard. +fn enter_capped_budget(max_operations: u64) -> wfl::exec::budget::CurrentBudgetGuard { + let limits = wfl::exec::budget::BudgetLimits { + max_operations: Some(max_operations), + ..Default::default() + }; + let budget = std::sync::Arc::new(wfl::exec::budget::ExecutionBudget::new(limits)); + wfl::exec::budget::ExecutionBudget::enter(budget) +} + +#[test] +fn analyzer_polls_the_budget_inside_nested_bodies() { + // The entry checkpoint in `analyze` fires once, before traversal. Only the + // recursive checkpoint in `analyze_statement` can trip the operation cap + // from inside the loop body, so tripping it here proves nested analysis + // honors the budget rather than only the phase boundary. + let program = parse_deeply_nested_program(); + let _guard = enter_capped_budget(5); + + let result = wfl::analyzer::Analyzer::new().analyze(&program); + let errors = result.expect_err("nested analysis must trip the operation cap"); + assert!( + format!("{errors:?}").contains("operation budget"), + "analyzer must surface the operation-budget breach from a nested body; got: {errors:?}" + ); +} + +#[test] +fn type_checker_polls_the_budget_inside_nested_bodies() { + // `with_analyzer` skips the analyzer pass, so the breach can only come from + // the recursive checkpoint in `check_statement_types` — not the analyzer and + // not a top-level-only poll. The fatal `TypeCheckError::Budget` variant + // proves the nested type-check surfaced the breach on the fatal channel. + let program = parse_deeply_nested_program(); + + let mut analyzer = wfl::analyzer::Analyzer::new(); + wfl::stdlib::typechecker::register_stdlib_types(&mut analyzer); + + let _guard = enter_capped_budget(5); + + let mut type_checker = wfl::typechecker::TypeChecker::with_analyzer(analyzer); + let outcome = type_checker.check_types(&program); + assert!( + matches!(outcome, Err(wfl::typechecker::TypeCheckError::Budget(_))), + "nested type checking must surface the operation-budget breach as the fatal TypeCheckError::Budget variant; got: {outcome:?}" + ); +} + +#[tokio::test] +async fn task_local_budget_is_isolated_across_interleaved_runs() { + // The regression that motivated task-local scoping: two runs with DISTINCT + // budgets interleaved on ONE thread (a library embedder `join!`ing two + // `!Send` interpreter futures) must each keep seeing their OWN budget across + // an `.await`. A thread-local held across the await would let the second run + // overwrite the first's current budget and corrupt it. `#[tokio::test]` + // defaults to a current-thread runtime, so `join!` genuinely interleaves the + // two futures at the `yield_now` points on a single thread. + use std::sync::Arc; + use wfl::exec::budget::{BudgetLimits, ExecutionBudget}; + + fn budget_with_ops(n: u64) -> Arc { + Arc::new(ExecutionBudget::new(BudgetLimits { + max_operations: Some(n), + ..Default::default() + })) + } + + async fn observe_across_await() -> (Option, Option) { + let first = ExecutionBudget::current().and_then(|b| b.limits().max_operations); + tokio::task::yield_now().await; // hand control to the sibling run + let second = ExecutionBudget::current().and_then(|b| b.limits().max_operations); + (first, second) + } + + let run_a = ExecutionBudget::scope(budget_with_ops(11), observe_across_await()); + let run_b = ExecutionBudget::scope(budget_with_ops(22), observe_across_await()); + let (a, b) = tokio::join!(run_a, run_b); + + assert_eq!( + a, + (Some(11), Some(11)), + "run A must see only its own budget" + ); + assert_eq!( + b, + (Some(22), Some(22)), + "run B must see only its own budget" + ); +} + +#[test] +fn run_with_interpreter_stack_provides_a_large_stack() { + // The public helper embedders use to get the CLI's stack safety must run its + // work on a genuinely large stack: recursion deep enough to overflow the + // default (2 MiB) test-thread stack completes when driven through the helper. + fn deep(n: u64) -> u64 { + // ~4 KiB per frame, so ~20k frames need ~80 MiB — far past 2 MiB, far + // under the reserved 1 GiB. `black_box` blocks tail-call/dead-code + // optimization so these are real stack frames. + let filler = [0u8; 4096]; + std::hint::black_box(&filler); + if n == 0 { + 0 + } else { + deep(n - 1).wrapping_add(u64::from(filler[0])) + } + } + + let result = + wfl::run_with_interpreter_stack(|| deep(20_000)).expect("large stack should spawn"); + assert_eq!(result, 0); +} + +#[test] +fn parser_polls_the_budget_inside_one_huge_expression() { + // A single statement whose expression is a giant list literal. The + // statement-boundary checkpoint fires only once (there is one statement), so + // only the strided per-operand checkpoint in expression parsing can trip the + // budget — proving a huge single expression is now interruptible, not parsed + // to completion after the deadline. + let mut src = String::from("store big as ["); + for i in 0..6000 { + if i > 0 { + src.push_str(", "); + } + src.push('1'); + } + src.push_str("]\n"); + + // Lex with no budget installed so the parser (not the lexer) is what trips. + let tokens = wfl::lexer::lex_wfl_with_positions(&src); + let _guard = enter_capped_budget(1); + let result = wfl::parser::Parser::new(&tokens).parse(); + assert!( + result.is_err(), + "a huge single expression must trip the strided parser budget checkpoint" + ); +} + +#[test] +fn analyzer_phase_budget_breach_is_fatal_and_typed() { + // `TypeChecker::new()` runs the analyzer internally before type checking. A + // breach during that analysis phase must surface as the fatal + // `TypeCheckError::Budget` variant — not be silently downgraded to ordinary + // `TypeCheckError::Types` diagnostics. This guards the gap the maintainer + // flagged: the analyzer's breach used to be rendered as `TypeError`s while + // the fatal budget channel stayed empty, so a caller checking it saw nothing. + let program = parse_deeply_nested_program(); + // cap = 1: the analyzer trips on its first statement (before the type-check + // loop is even reached), so the breach can only travel out through + // `check_types`'s analyzer-propagation path. + let _guard = enter_capped_budget(1); + let mut type_checker = wfl::typechecker::TypeChecker::new(); + let outcome = type_checker.check_types(&program); + assert!( + matches!(outcome, Err(wfl::typechecker::TypeCheckError::Budget(_))), + "an analysis-phase budget breach must surface as the fatal TypeCheckError::Budget variant; got: {outcome:?}" + ); +} + +#[test] +fn analyzer_entry_budget_breach_is_fatal_and_typed() { + // Companion to the test above, targeting the analysis PHASE BOUNDARY: a + // budget already cancelled/exhausted when `analyze` is entered trips its + // entry checkpoint *before* any statement is visited. That branch must still + // record the typed breach on `budget_error`, so `TypeChecker::new()`'s + // `take_budget_error()` reclassifies it as the fatal `Budget` variant rather + // than misreading the rendered `SemanticError` as ordinary type errors. A + // cancelled budget guarantees the entry checkpoint (not `analyze_statement`) + // is what fires. + use wfl::exec::budget::{BudgetLimits, ExecutionBudget}; + let program = parse_deeply_nested_program(); + let budget = std::sync::Arc::new(ExecutionBudget::new(BudgetLimits::default())); + budget.cancel(); + let _guard = ExecutionBudget::enter(budget); + let mut type_checker = wfl::typechecker::TypeChecker::new(); + let outcome = type_checker.check_types(&program); + assert!( + matches!(outcome, Err(wfl::typechecker::TypeCheckError::Budget(_))), + "an entry-time budget breach must surface as the fatal TypeCheckError::Budget variant; got: {outcome:?}" + ); +} + +// --- lexer: a typed fatal outcome, never a truncated success (P1-1) --------- + +/// A source with well over `LEX_CHECKPOINT_STRIDE` (4096) raw tokens, so the +/// lexer's strided budget checkpoint fires several times. Each `display 1` line +/// is three raw tokens (`display`, `1`, newline), so 4000 lines ≈ 12k tokens → +/// checkpoints near 4096, 8192, and 12288. +fn big_lexer_source() -> String { + "display 1\n".repeat(4000) +} + +#[test] +fn checked_lexer_reports_cancellation_not_a_partial_stream() { + use wfl::exec::budget::{BudgetExceeded, BudgetLimits, ExecutionBudget}; + let budget = std::sync::Arc::new(ExecutionBudget::new(BudgetLimits::default())); + budget.cancel(); + let _guard = ExecutionBudget::enter(std::sync::Arc::clone(&budget)); + let result = wfl::lexer::lex_wfl_with_positions_checked(&big_lexer_source()); + assert_eq!( + result.err(), + Some(BudgetExceeded::Cancelled), + "a cancelled run must abort lexing with a typed Cancelled, not a truncated stream" + ); +} + +#[test] +fn checked_lexer_reports_deadline() { + use wfl::exec::budget::{BudgetExceeded, BudgetLimits, ExecutionBudget}; + let limits = BudgetLimits { + max_duration: Some(std::time::Duration::from_secs(0)), + ..Default::default() + }; + let budget = std::sync::Arc::new(ExecutionBudget::new(limits)); + let _guard = ExecutionBudget::enter(budget); + let result = wfl::lexer::lex_wfl_with_positions_checked(&big_lexer_source()); + assert!( + matches!(result, Err(BudgetExceeded::Deadline { .. })), + "an elapsed deadline must abort lexing with a typed Deadline; got: {result:?}" + ); +} + +#[test] +fn checked_lexer_reports_operation_exhaustion() { + use wfl::exec::budget::{BudgetExceeded, BudgetLimits, ExecutionBudget}; + // One operation is allowed; the second strided checkpoint (~token 8192) + // charges index 1 >= 1 and trips. `big_lexer_source` spans past two strides. + let limits = BudgetLimits { + max_operations: Some(1), + max_duration: None, + ..Default::default() + }; + let budget = std::sync::Arc::new(ExecutionBudget::new(limits)); + let _guard = ExecutionBudget::enter(budget); + let result = wfl::lexer::lex_wfl_with_positions_checked(&big_lexer_source()); + assert!( + matches!(result, Err(BudgetExceeded::Operations { .. })), + "an exhausted operation budget must abort lexing with a typed Operations; got: {result:?}" + ); +} + +#[test] +fn checked_lexer_reports_cancellation_on_a_short_input() { + // A source far shorter than one `LEX_CHECKPOINT_STRIDE` (here ~3 tokens) must + // still observe an already-cancelled budget: the boundary checkpoint runs + // before the first token, so the breach is caught even though no strided + // checkpoint (token 4096+) is ever reached. This is the `--lex` gap — that + // path has no later phase to catch the breach. + use wfl::exec::budget::{BudgetExceeded, BudgetLimits, ExecutionBudget}; + let budget = std::sync::Arc::new(ExecutionBudget::new(BudgetLimits::default())); + budget.cancel(); + let _guard = ExecutionBudget::enter(budget); + let result = wfl::lexer::lex_wfl_with_positions_checked("display 1\n"); + assert_eq!( + result.err(), + Some(BudgetExceeded::Cancelled), + "a short input under an already-cancelled budget must abort at the entry checkpoint" + ); +} + +#[test] +fn checked_lexer_reports_expired_deadline_on_a_short_input() { + // Companion to the cancellation case: an already-expired deadline must abort + // a short lex at the entry checkpoint, not slip through because the input is + // too short to reach a strided checkpoint. + use wfl::exec::budget::{BudgetExceeded, BudgetLimits, ExecutionBudget}; + let limits = BudgetLimits { + max_duration: Some(std::time::Duration::from_secs(0)), + ..Default::default() + }; + let budget = std::sync::Arc::new(ExecutionBudget::new(limits)); + let _guard = ExecutionBudget::enter(budget); + let result = wfl::lexer::lex_wfl_with_positions_checked("display 1\n"); + assert!( + matches!(result, Err(BudgetExceeded::Deadline { .. })), + "a short input under an already-expired deadline must abort at the entry checkpoint; got: {result:?}" + ); +} + +#[test] +fn checked_lexer_returns_the_complete_stream_within_budget() { + // A generous budget lexes the whole source: the checked variant returns the + // FULL token stream (same length as the unbudgeted lexer), never a prefix. + use wfl::exec::budget::{BudgetLimits, ExecutionBudget}; + let src = big_lexer_source(); + let full = wfl::lexer::lex_wfl_with_positions(&src).len(); + let budget = std::sync::Arc::new(ExecutionBudget::new(BudgetLimits::default())); + let _guard = ExecutionBudget::enter(budget); + let checked = wfl::lexer::lex_wfl_with_positions_checked(&src).expect("within budget"); + assert_eq!( + checked.len(), + full, + "a within-budget checked lex must return the complete stream" + ); +} + +#[test] +fn plain_lexer_never_truncates_even_under_a_breached_budget() { + // The plain (non-budgeted) lexer must NEVER return a truncated stream — the + // silent-truncation footgun is gone. Even with a cancelled budget installed, + // it tokenizes the whole source (its checkpoint is a no-op), so a caller that + // deliberately uses it (the LSP, tooling, tests) is unaffected by run state. + use wfl::exec::budget::{BudgetLimits, ExecutionBudget}; + let src = big_lexer_source(); + let without = wfl::lexer::lex_wfl_with_positions(&src).len(); + let budget = std::sync::Arc::new(ExecutionBudget::new(BudgetLimits::default())); + budget.cancel(); + let _guard = ExecutionBudget::enter(budget); + let with = wfl::lexer::lex_wfl_with_positions(&src).len(); + assert_eq!( + with, without, + "the plain lexer must not truncate under any budget state" + ); +} + +#[test] +fn cli_lex_dump_fails_on_a_budget_breach_instead_of_a_partial_dump() { + // `wfl --lex` must NOT write a partial token dump and exit 0 when the run + // budget is breached during lexing. With `max_operations = 1` and a source + // past two lexer strides, tokenization trips and the CLI exits non-zero with + // the breach message, writing no `.lex.txt`. + let dir = tempfile::tempdir().expect("temp dir"); + fs::write(dir.path().join(".wflcfg"), "max_operations = 1\n").expect("cfg"); + let script = dir.path().join("big.wfl"); + fs::write(&script, big_lexer_source()).expect("program"); + let output = Command::new(test_helpers::get_wfl_binary_path()) + .arg("--lex") + .arg(&script) + .output() + .expect("run wfl --lex"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !output.status.success(), + "--lex must fail on a budget breach; got:\n{combined}" + ); + assert!( + combined.contains("operation budget"), + "--lex must report the budget breach; got:\n{combined}" + ); + assert!( + !dir.path().join("big.wfl.lex.txt").exists(), + "--lex must not write a partial token dump on a breach" + ); +} + +// --- default public interpreter path is stack-safe (P2-5) ------------------- + +#[test] +fn default_public_interpreter_path_is_stack_safe_on_an_ordinary_stack() { + // An embedder using the DEFAULT public path — `Interpreter::new()` — on an + // ordinary 8 MiB thread stack must get a catchable call-depth resource error + // from runaway recursion, NOT a native stack overflow. `new()` caps recursion + // at the conservative `DEFAULT_EMBED_CALL_DEPTH`, so the guard fires well + // before the ~40-frame debug overflow point on such a stack. (The CLI reaches + // the full 1000 only on its dedicated 1 GiB stack.) A regression that let the + // default path use depth 1000 here would overflow this 8 MiB stack and abort. + // The interpreter's `Value`/`RuntimeError` are `!Send`, so reduce the outcome + // to a `Send` summary INSIDE the thread and return only that. + let handle = std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(|| -> Result<(), String> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + rt.block_on(async { + // No base case: recurses until the depth guard fires. + let program = "\ +define action called deep with parameters n: + return deep of (n plus 1) +end action +display deep of 0 +"; + let tokens = wfl::lexer::lex_wfl_with_positions(program); + let ast = wfl::parser::Parser::new(&tokens).parse().expect("parses"); + match wfl::Interpreter::new().interpret(&ast).await { + Ok(_) => Err("runaway recursion unexpectedly succeeded".to_string()), + Err(errors) + if errors + .iter() + .any(|e| e.message.contains("Maximum call depth")) => + { + Ok(()) + } + Err(errors) => Err(format!( + "unexpected error (not a call-depth breach): {errors:?}" + )), + } + }) + }) + .expect("spawn ordinary-stack thread"); + let summary = handle + .join() + .expect("the default path must not abort with a native stack overflow"); + summary.expect("the default public path must return a call-depth resource error"); +} diff --git a/tests/export_constant_mutability_test.rs b/tests/export_constant_mutability_test.rs index af7c8654..0b124c0a 100644 --- a/tests/export_constant_mutability_test.rs +++ b/tests/export_constant_mutability_test.rs @@ -28,7 +28,7 @@ export constant mutable_var "Type checking should fail when exporting mutable variable as constant" ); - let errors = result.unwrap_err(); + let errors = result.unwrap_err().into_diagnostics(); assert!( !errors.is_empty(), "Should have type errors for mutable constant export" @@ -93,7 +93,7 @@ export constant MISSING_CONSTANT "Type checking should fail when exporting non-existent constant" ); - let errors = result.unwrap_err(); + let errors = result.unwrap_err().into_diagnostics(); assert!( !errors.is_empty(), "Should have type errors for missing constant" diff --git a/tests/export_statement_test.rs b/tests/export_statement_test.rs index 151e3ad2..67babdc9 100644 --- a/tests/export_statement_test.rs +++ b/tests/export_statement_test.rs @@ -282,7 +282,7 @@ export constant mutable_var "Type checking should fail when exporting mutable variable as constant" ); - let errors = result.unwrap_err(); + let errors = result.unwrap_err().into_diagnostics(); assert!( !errors.is_empty(), "Should have type errors for mutable constant export" diff --git a/tests/nothing_reassign_widen_test.rs b/tests/nothing_reassign_widen_test.rs index b0081973..e277d4d8 100644 --- a/tests/nothing_reassign_widen_test.rs +++ b/tests/nothing_reassign_widen_test.rs @@ -45,7 +45,9 @@ fn assert_type_error_contains(code: &str, needle: &str) { let mut type_checker = TypeChecker::new(); let result = type_checker.check_types(&program); - let errors = result.expect_err("Expected at least one type error"); + let errors = result + .expect_err("Expected at least one type error") + .into_diagnostics(); assert!( errors.iter().any(|e| e.message.contains(needle)), "Expected a type error containing {needle:?}, got: {errors:?}" diff --git a/tests/web_admission_reopens_after_timeout_test.rs b/tests/web_admission_reopens_after_timeout_test.rs new file mode 100644 index 00000000..0ee310e1 --- /dev/null +++ b/tests/web_admission_reopens_after_timeout_test.rs @@ -0,0 +1,110 @@ +//! Regression for the admission-cap deadlock (issue #611, P1-2). +//! +//! When the global in-flight admission cap fills with requests the handler +//! dequeued but never answered, the route tasks eventually hit their response +//! timeout — and the freed admission slots MUST reopen WITHOUT needing another +//! admitted request first. Previously each dequeued request's admission guard +//! was parked in the interpreter's pending map and released only when a *later* +//! dequeued request was inserted (which pruned closed entries). Once the cap was +//! full no new request could be admitted to trigger that prune, so every slot +//! stayed pinned and the server was permanently wedged. The admission guard now +//! rides with the warp transport task, so a response timeout (or disconnect) +//! releases the slot independently of any future admission. + +use std::sync::Arc; +use std::time::Duration; +use wfl::Interpreter; +use wfl::config::WflConfig; +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; + +/// Start a WFL server on its own thread + runtime with a custom config. +fn start_server(code: String, config: WflConfig) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + rt.block_on(async { + let tokens = lex_wfl_with_positions(&code); + let ast = Parser::new(&tokens).parse().expect("parse server code"); + let mut interpreter = Interpreter::with_config(Arc::new(config)); + let _ = interpreter.interpret(&ast).await; + }); + }) +} + +fn client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("build client") +} + +#[tokio::test] +async fn admission_reopens_after_pending_requests_time_out() { + let port = 8241; + + // Cap in-flight admission at 2 and shed a dequeued-but-unanswered request + // after 1s. The handler dequeues requests in a loop and NEVER responds, so + // each admitted request pins its slot until its route task times out. + let config = WflConfig { + web_server_request_queue_bound: 2, + web_server_response_timeout_seconds: 1, + ..Default::default() + }; + + let server_code = format!( + "\ +listen on port {port} as test_server +count from 1 to 100: + wait for request comes in on test_server as req with timeout 30000 +end count +" + ); + + let _server = start_server(server_code, config); + // Let the listener bind. + tokio::time::sleep(Duration::from_millis(500)).await; + + let url = format!("http://127.0.0.1:{port}/"); + + // Fill both admission slots with requests the handler dequeues but never + // answers. Each client stays connected and receives a 504 when its route + // task times out (~1s) — the TIMEOUT path, not a client disconnect. + let mut fillers = Vec::new(); + for _ in 0..2 { + let url = url.clone(); + fillers.push(tokio::spawn(async move { + client().get(&url).send().await.map(|r| r.status().as_u16()) + })); + } + + // Wait past the 1s response timeout so both route tasks have shed their + // requests and released their slots. + tokio::time::sleep(Duration::from_secs(3)).await; + for f in fillers { + let status = f.await.expect("filler task").expect("filler response"); + assert_eq!( + status, 504, + "a dequeued-but-unanswered request should time out with 504" + ); + } + + // With the fix, admission has reopened WITHOUT another request being admitted + // first, so this request reaches the server (and, since the handler still + // never answers, itself times out with 504). Before the fix every slot + // stayed pinned and this would be shed with 503. + let third = client() + .get(&url) + .send() + .await + .expect("third request should reach the server, not be shed"); + assert_ne!( + third.status().as_u16(), + 503, + "admission must reopen after pending requests time out (503 = still wedged)" + ); + assert_eq!( + third.status().as_u16(), + 504, + "the admitted third request should itself time out with 504" + ); +} diff --git a/wfl-lsp/src/core.rs b/wfl-lsp/src/core.rs index e1924d24..73f7709d 100644 --- a/wfl-lsp/src/core.rs +++ b/wfl-lsp/src/core.rs @@ -117,10 +117,12 @@ impl WflLanguageCore { } } - // Run type checking + // Run type checking. A shared-budget breach is surfaced as a + // diagnostic here (the LSP only reports; it never executes), so + // rendering it via `into_diagnostics` is sufficient. let mut type_checker = TypeChecker::new(); - if let Err(errors) = type_checker.check_types(&program) { - for error in errors { + if let Err(failure) = type_checker.check_types(&program) { + for error in failure.into_diagnostics() { let wfl_diag = diagnostic_reporter.convert_type_error(file_id, &error); diagnostics.push(wfl_diag); } diff --git a/wfl-lsp/src/mcp_server.rs b/wfl-lsp/src/mcp_server.rs index 12a006d1..4f610298 100644 --- a/wfl-lsp/src/mcp_server.rs +++ b/wfl-lsp/src/mcp_server.rs @@ -465,7 +465,8 @@ impl WflMcpServer { error: None, } } - Err(errors) => { + Err(failure) => { + let errors = failure.into_diagnostics(); let error_messages: Vec = errors.iter().map(|e| format!("{:?}", e)).collect(); diff --git a/wfl-lsp/tests/lsp_diagnostics_test.rs b/wfl-lsp/tests/lsp_diagnostics_test.rs index 213d3dba..0ff1d1fb 100644 --- a/wfl-lsp/tests/lsp_diagnostics_test.rs +++ b/wfl-lsp/tests/lsp_diagnostics_test.rs @@ -203,7 +203,8 @@ async fn test_lsp_diagnostic_conversion_for_type_errors() { // If type checker is lenient, that's acceptable println!("Note: Type checker might be lenient with type mismatches"); } - Err(errors) => { + Err(failure) => { + let errors = failure.into_diagnostics(); assert!(!errors.is_empty(), "Should have type errors"); // Convert first error to WFL diagnostic diff --git a/wfl-lsp/tests/lsp_end_to_end_test.rs b/wfl-lsp/tests/lsp_end_to_end_test.rs index daa7f6da..4a17c948 100644 --- a/wfl-lsp/tests/lsp_end_to_end_test.rs +++ b/wfl-lsp/tests/lsp_end_to_end_test.rs @@ -63,7 +63,8 @@ async fn test_lsp_with_basic_syntax_program() { Ok(_) => { println!("Type checking passed for basic syntax program"); } - Err(errors) => { + Err(failure) => { + let errors = failure.into_diagnostics(); println!("Type checking errors (may be expected): {:?}", errors); // Convert errors to diagnostics to test LSP diagnostic conversion for error in &errors { @@ -255,8 +256,8 @@ async fn test_lsp_with_multiple_test_programs() { } } - if let Err(errors) = type_res { - for error in &errors { + if let Err(failure) = type_res { + for error in &failure.into_diagnostics() { let _diag = diagnostic_reporter.convert_type_error(file_id, error); } } diff --git a/wfl-lsp/tests/lsp_end_to_end_validation_test.rs b/wfl-lsp/tests/lsp_end_to_end_validation_test.rs index 76be57d8..8532fb51 100644 --- a/wfl-lsp/tests/lsp_end_to_end_validation_test.rs +++ b/wfl-lsp/tests/lsp_end_to_end_validation_test.rs @@ -59,8 +59,8 @@ fn validate_lsp_workflow(document_text: &str, filename: &str) -> LSPWorkflowResu let mut type_checker = TypeChecker::new(); match type_checker.check_types(&program) { Ok(_) => result.type_checking_success = true, - Err(errors) => { - result.error_count += errors.len(); + Err(failure) => { + result.error_count += failure.into_diagnostics().len(); result.diagnostics_generated = true; } } diff --git a/wfl-lsp/tests/lsp_server_test.rs b/wfl-lsp/tests/lsp_server_test.rs index cb0a12bf..b317a16b 100644 --- a/wfl-lsp/tests/lsp_server_test.rs +++ b/wfl-lsp/tests/lsp_server_test.rs @@ -152,7 +152,8 @@ async fn test_wfl_type_checking_errors() { // This might pass if the type checker is lenient println!("Warning: Type checker should catch number + string type mismatch"); } - Err(errors) => { + Err(failure) => { + let errors = failure.into_diagnostics(); // Should have type errors assert!( !errors.is_empty(),