Skip to content

Update WFL Documentation to Align with hello.wfl Standard - #6

Closed
devin-ai-integration[bot] wants to merge 1 commit into
masterfrom
devin/1744812813-update-docs-to-hello-standard
Closed

Update WFL Documentation to Align with hello.wfl Standard#6
devin-ai-integration[bot] wants to merge 1 commit into
masterfrom
devin/1744812813-update-docs-to-hello-standard

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Update WFL Documentation to Align with hello.wfl Standard

This PR updates the WFL language documentation to align with hello.wfl as the standard implementation of the language. The documentation has been rewritten to accurately reflect the syntax and design principles demonstrated in hello.wfl.

Changes

  • Simplified the project statement and guiding principles to focus on core language design philosophy
  • Updated variable and data type documentation to match hello.wfl syntax
  • Revised function (action) and container documentation to reflect the standard implementation
  • Updated control flow and error handling sections to match hello.wfl examples
  • Reorganized and streamlined the documentation for better readability

Link to Devin run

https://app.devin.ai/sessions/065bcb803fd44c049aea84afdc3064b9

Requested by

bsbyrd@logbie.com

Co-Authored-By: bsbyrd@logbie.com <bsbyrd@logbie.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@logbie logbie closed this Apr 17, 2025
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

devin-ai-integration Bot added a commit that referenced this pull request Apr 19, 2025
Co-Authored-By: bsbyrd@logbie.com <bsbyrd@logbie.com>
@logbie
logbie deleted the devin/1744812813-update-docs-to-hello-standard branch April 22, 2025 11:28
logbie pushed a commit that referenced this pull request Jul 12, 2026
Round-3 maintainer review (web findings):

- (#4) Cap the WebSocket transport itself: apply max_ws_message_bytes via
  warp Ws::max_message_size/max_frame_size before on_upgrade, so a fragmented
  text frame or ignored binary frame cannot allocate up to Tungstenite's
  defaults on the receive side. The queued-byte permit remains the global layer.
- (#6) Fail closed when the Connect event cannot be admitted: unregister the
  connection and close the socket instead of leaving a live socket whose connect
  handler (app init/auth) never ran but which could still emit Message events.
- (#11) Reserve the WS queued-byte permit on the *borrowed* payload length
  (ws_message_byte_len) before materializing the String, on both send and
  broadcast, so an oversized runtime value is never fully cloned first; an
  oversized broadcast is rejected before it is materialized once.
- (#14) Drop cleanup for WflWebServer and WflWebSocketServer: abort the accept
  task (and signal WS close) so a dropped/replaced/setup-errored server can't
  orphan a bound listener; `close server` .take()s the handle so no double-abort.
- (#5) Carry the global admission RequestGuard *inside* the queued
  WflHttpRequest (no longer Clone), so a queued body counts against the pending
  ceiling until the interpreter dequeues/drops it — a timed-out route future no
  longer un-accounts a still-queued body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y498gapRYjXfGSciauq49A
logbie added a commit that referenced this pull request Jul 12, 2026
…609)

* feat: add shared ExecutionBudget consolidating runtime resource caps

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

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

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

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

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

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

* fix: address PR review — budget spans execute-file, nested source caps, catchable-error stack

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

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

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

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

* fix(test): use forward-slash paths in execute-file budget tests (Windows CI)

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

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

* fix(P1): one budget per run + bounded source loader for every entry point

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

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

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

* fix(P1-1): dedicated RAII recursion-depth counter; don't corrupt state on catch

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

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

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

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

* fix(P1-2): meter pattern transitions per-instruction on one shared budget

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

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

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

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

* fix(P2): register budget keys with ConfigChecker; document real MSRV

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

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

* fix(P1-3): stream HTTP body, global request guard + timeout, reliable WS close

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

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

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

* docs: record deep-review round (P1-1..P1-5 + P2) in Dev Diary

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

* fix(P1): per-match pattern meter with deadline sampling; keep VM API

Round-2 maintainer review (pattern findings):

- P1 (mod.rs:295): the pattern transition counter no longer lives on the
  shared run budget. Two matches that share one Arc<ExecutionBudget> (e.g.
  concurrent web handlers) previously reset each other's meter, letting one
  grant the other unbounded extra quota. Introduce a per-top-level-match
  PatternMeter that owns its own step/state counters and is cloned only into
  nested lookaround/lookbehind VMs; a second match under the same run budget
  gets an independent meter.
- P1 (budget.rs:519): the meter now samples the wall-clock deadline on the
  transition stride (with the interpreter's main-loop exemption, tracked via
  a new deadline_exempt flag synced by set_in_main_loop) and propagates it as
  PatternError::Timeout -> ErrorKind::Timeout, so a single synchronous match
  cannot run past timeout_seconds and reports the historic timeout error.
- P2 (vm.rs:267): active-state slots are reserved/released across the current,
  next, and nested frontiers via one RAII StateReservation, so the ceiling
  counts all simultaneously-live states (including under nesting), not just
  one output vector.
- P2 (vm.rs:203): restore the public PatternVM API — find -> Option and
  find_all -> Vec compatible wrappers, with separately-named fallible
  try_find/try_find_all entry points; direct top-level ops reset their own
  meter while private no-reset runners feed nested lookarounds.

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

* fix(P1): bound HTTP request lifetime; prune abandoned requests

Round-2 maintainer review (inbound HTTP findings):

- P1 (interpreter:5751): establish one deadline at admission and apply its
  remaining time to the *body read* as well as the response wait. Previously
  only the response wait was bounded, so an unauthenticated client could open
  max_pending_requests chunked uploads and trickle bytes under the size cap
  forever, pinning every global in-flight slot (503 for everyone). A slow body
  now sheds with 408; the handler wait still sheds with 504, sharing the same
  deadline.
- P1 (interpreter:5836): on timeout the dropped oneshot closes its sender, and
  the interpreter now (a) skips a dequeued request whose sender is already
  closed (client gave up) instead of running a zombie handler, and (b) prunes
  closed entries from pending_responses before registering a new one, so
  repeated timeouts can't accumulate dead map entries or handler work.
- P2 (interpreter:6429): reject composite/opaque response bodies (lists,
  objects, …) with a clear error instead of materializing an unbounded `{:?}`
  string past the response cap; take the response sender into an RAII
  ResponseCompletion guard up front so every fallible step in `respond`
  resolves the request (500 on early error) rather than leaving the client to
  hang until timeout.
- P2 (interpreter:203): keep read_body_capped's max+1 bound exact — copy at
  most the remaining allowance plus one sentinel byte from a chunk and return
  TooLarge without appending the rest, so one large Buf::chunk() can't grow the
  buffer to max+chunk_len.

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

* fix(P1): bound WebSocket queued bytes; cancellable close handshake

Round-2 maintainer review (WebSocket findings):

- P1 (interpreter:373): bound WebSocket memory by BYTES, not only frame count.
  Add a budget-derived per-message size cap (web_socket_max_message_size,
  default 1 MiB) enforced on inbound and outbound text frames, plus a global
  queued-byte ceiling (web_socket_max_queued_bytes, default 16 MiB) reserved
  per queued frame via an RAII WsBytePermit that releases on send/consume/shed.
  Previously the event and per-connection outbound queues could each hold
  ws_queue_bound arbitrarily-large messages, permitting OOM-scale buffering.
- P2 (interpreter:354): a close frame alone did not guarantee the reader task
  (blocked in ws_rx.next()) or its WsConnectionGuard terminated when the peer
  ignored the close handshake. Give each server a watch-based cancellation
  channel; the reader now select!s on it so `close server` wakes every
  connection, with a bounded (250 ms) close-handshake timeout before the writer
  is force-aborted.

Adds config keys web_socket_max_message_size and web_socket_max_queued_bytes
(both positive-integer validated; mapped into BudgetLimits).

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

* fix(P1): share recursion depth across execute-file boundary

Round-2 maintainer review (interpreter:7232): a child interpreter spawned by
`execute file` started with call_depth = 0 (and interpret() reset it again), so
a parent already near max_call_depth could run a child that consumed another
full allowance — and nested execute files multiplied the native stack toward
1 GiB-thread overflow before any guard fired.

The child now seeds its base recursion depth from the parent's live depth
(base_call_depth), and interpret() resets to that base rather than 0, so the
combined WFL call depth across nested execute-file runs is bounded by
max_call_depth. Regression test execute_file_shares_the_parent_recursion_depth
pins that a child cannot get a fresh depth allowance.

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

* fix(P2): config checker validates budget keys against loader ranges

Round-2 maintainer review (checker.rs:560): the budget keys were registered as
generic Integer, validated with parse::<i64>() and no per-key range, so
max_operations = -1 or max_call_depth = 0 passed check/fix even though the
loader rejects them (leaving max_operations unlimited), and valid u64 values
above i64::MAX were wrongly rejected and could be "fixed" to 0/unlimited.

Integer keys are now validated as non-negative u64 (the loader's usize/u64
domain), and each budget/web key enforces the loader's exact minimum via
integer_min_for_key (max_operations and web_server_response_timeout_seconds
allow 0; every other ceiling requires >= 1). Also add the Execution Budget
category to get_settings_by_category so the config wizard no longer omits every
setting in it, and register the two new web_socket_max_message_size /
web_socket_max_queued_bytes keys. New test test_budget_keys_enforce_loader_ranges.

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

* fix(P1/P2): REPL enforces source cap pre-lex; per-command budget + Ctrl-C

Round-2 maintainer review (REPL findings):

- P1 (repl.rs:149): enforce the source-size ceiling immediately after appending
  a line to the input buffer, before it is cloned/lexed/parsed — an oversized
  incomplete paste was previously retained and re-cloned/re-tokenized on every
  following line, defeating the pre-lexing cap. Also load the applicable
  `.wflcfg` (via load_config_with_global) instead of WflConfig::default(), so a
  configured smaller max_source_size / timeout is honored.
- P2 (repl.rs:49): give each command a fresh budget (new wall-clock deadline,
  cleared counters) via reset_command_budget while preserving the session
  environment, instead of disabling the deadline for the whole session — so the
  first runaway command still times out. Wire Ctrl-C during execution to
  budget.cancel() (cooperative cancellation), racing the command future against
  tokio::signal::ctrl_c; Ctrl-C while awaiting input is still handled by
  rustyline.

Adds Interpreter::set_budget/config accessors and a REPL budget-reset test.

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

* fix(P1): thread the budget through the front end

Round-2 maintainer review (main.rs:828): starting the budget's clock did not
make lexing, parsing, analysis, or type checking consult it — none received the
budget, so `--analyze` and the dump modes could finish without checking, and a
slow/oversized parse could not be cancelled mid-phase; the budget only measured
elapsed time for later interpretation.

main.rs now installs the run budget as the current-thread budget for the ENTIRE
run (source read through interpretation), so every mode consults one budget. The
parser polls it once per top-level statement, the type checker once per
top-level statement, and the analyzer at its phase boundary — each honoring the
deadline and cooperative cancellation and aborting cleanly on breach. The
source read is already bounded (read_source_bounded), so lexing/parsing are
bounded by max_source_size. New test analyze_mode_consults_the_budget_in_the_
front_end proves --analyze (which never interprets) surfaces a budget breach.

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

* docs: document round-2 budget changes (WS bytes, request deadline, front end)

- Configuration reference: add web_socket_max_message_size and
  web_socket_max_queued_bytes (summary + detailed), fix the stale
  max_pattern_steps default (5_000_000).
- Web servers guide: document the single admission deadline (408 body timeout /
  504 handler timeout), abandoned-request pruning, WebSocket per-message and
  global queued-byte bounds, and the deterministic close-server handshake.
- Dev Diary: record the second deep-review round (P1×8 + P2×7).

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

* fix: address round-2 bot review (deadline exemption, yield, checker)

- Nested `execute file` now preserves the parent's `deadline_exempt`: the child
  shares the budget and its interpret() clears the shared flag, which would make
  a parent still inside its own `main loop` enforce the wall-clock deadline on
  later pattern matches and time out spuriously. Save/restore around the child.
- Add a throttled cooperative `tokio::task::yield_now()` in _execute_statement
  (outside a `main loop`) so a tight CPU-bound count/while/repeat loop returns
  control to the runtime — letting the REPL's Ctrl-C → budget.cancel() actually
  be delivered by the select! (the per-command deadline was already the safety
  net; this makes manual interruption work).
- Config checker: enforce the loader's minimums for timeout_seconds and
  web_server_max_body_size (both >= 1); extend the range test.
- Fix the stale budget_error doc that claimed the deadline force-releases the
  call stack — the helper mutates no state (every breach unwinds naturally).

Skipped two nitpicks with reason: the lookbehind run_execute/run_find_all
double-scan is bounded by the generous step ceiling and collapsing it to
find_at_position(0) is not semantically equivalent under greedy/non-overlapping
matching; the frontier-loop extraction is a pure refactor left to avoid churn.

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

* fix(P1): main-loop exemption as a shared depth counter + RAII guard

Round-3 maintainer review (#1,#2,#3,#13): the `main loop` deadline exemption was
a bool split between the interpreter (`in_main_loop`) and the budget
(`deadline_exempt`), which leaked on caught errors, didn't nest, wasn't inherited
across `execute file`, and was snapshotted by the pattern meter.

Replace it with one shared `main_loop_depth: AtomicUsize` on the budget plus an
RAII `MainLoopGuard` (enter_main_loop):
- (#2) the guard restores the depth on EVERY exit — normal, early return, a
  caught error unwinding through `?`, and nested loops — so the exemption is
  never leaked or cleared while an outer loop is active.
- (#3) a child `execute file` shares the budget, so the parent's active
  main-loop exemption covers the child and the nested front end; the
  parser/type-checker/analyzer checkpoints now charge with
  `!is_deadline_exempt()`, so `execute file` from a server's main-loop handler
  no longer times out in the nested parse. Removes the brittle save/restore.
- (#13) PatternMeter reads the exemption LIVE per sampled stride (no
  `enforce_deadline` snapshot), so reusing one VM across a main-loop boundary is
  correct. Test reuses one meter and toggles the budget.
- (#1) `_execute_statement` yields on a dedicated per-statement scheduling
  counter that advances even inside a `main loop` (whose op counter is exempt),
  so a CPU-only main loop still yields and lets the REPL Ctrl-C fire.

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

* fix(P1): harden WebSocket + HTTP lifecycle and peak allocation

Round-3 maintainer review (web findings):

- (#4) Cap the WebSocket transport itself: apply max_ws_message_bytes via
  warp Ws::max_message_size/max_frame_size before on_upgrade, so a fragmented
  text frame or ignored binary frame cannot allocate up to Tungstenite's
  defaults on the receive side. The queued-byte permit remains the global layer.
- (#6) Fail closed when the Connect event cannot be admitted: unregister the
  connection and close the socket instead of leaving a live socket whose connect
  handler (app init/auth) never ran but which could still emit Message events.
- (#11) Reserve the WS queued-byte permit on the *borrowed* payload length
  (ws_message_byte_len) before materializing the String, on both send and
  broadcast, so an oversized runtime value is never fully cloned first; an
  oversized broadcast is rejected before it is materialized once.
- (#14) Drop cleanup for WflWebServer and WflWebSocketServer: abort the accept
  task (and signal WS close) so a dropped/replaced/setup-errored server can't
  orphan a bound listener; `close server` .take()s the handle so no double-abort.
- (#5) Carry the global admission RequestGuard *inside* the queued
  WflHttpRequest (no longer Clone), so a queued body counts against the pending
  ceiling until the interpreter dequeues/drops it — a timed-out route future no
  longer un-accounts a still-queued body.

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

* fix(P1): scope budget across REPL/front end; fatal type-check budget breach

Round-3 maintainer review (scoping + front-end findings):

- (#9) The REPL now installs the per-command budget as the current-thread budget
  for the WHOLE pipeline (lex/parse/analyze/type-check/interpret), not just
  interpretation, so the front-end checkpoints actually see it (they saw None
  before, since interpret() installed the guard only for its own phase).
- (#10) A shared-budget breach during type checking is propagated on a distinct
  fatal channel (TypeChecker::take_budget_error); the CLI consults it and stops,
  instead of printing it as a non-fatal type-check "warning" and continuing.
- (#8) More front-end checkpoints: Analyzer::analyze (backs the type checker and
  every load/include/execute-file) checks the budget at its phase boundary, and
  parse_statement checks recursively (covering deeply nested block bodies the
  top-level parse loop never revisits) while advancing a token so the parser's
  no-progress invariant holds. All checkpoints are exemption-aware.
- (P2 repl:95) Check the prospective buffer length with checked_add BEFORE
  push_str, so a single oversized pasted line is never copied into the buffer.
- (P2 docs) Fix the detailed max_pattern_steps default (5000000).

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

* docs: record round-3 review (main-loop depth, WS/HTTP lifecycle, scoping)

Dev Diary: document the third deep-review round — the main-loop-depth + RAII
redesign, WebSocket/HTTP lifecycle hardening, budget scoping across the REPL and
front end, fatal type-check budget breaches, and the two deferred items
(task-local budget and pattern text-once) with rationale.

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

* fix: poll the execution budget inside recursive front-end traversal

The round-3 front-end checkpoints only covered phase entry / top-level
statements: `Analyzer::analyze` polled the budget once, 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 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`.

Move the poll into the recursive methods, mirroring the parser:

- Analyzer: `analyze_statement` polls at its top; a new `budget_exhausted`
  flag (reset per run) records the breach once and short-circuits the rest
  of the traversal so a large nested body yields one error, not one per
  statement. The phase-boundary poll in `analyze` is retained.
- Type checker: `check_statement_types` polls at its top, guarded by the
  existing `budget_error` channel (reset per run) as the record-once latch.
  The top-level loop no longer charges separately — it stops iterating once
  a breach is recorded, including one surfaced deep in a nested body.

Both polls stay exemption-aware. `max_operations` defaults to unlimited, so
ordinary programs are unaffected; the extra per-statement charging only
applies when a user opts into an operation cap.

Add two library-level regression tests that parse a program whose single
top-level `count` loop holds 64 nested statements before installing a
budget (so the parser cannot consume the cap), then run under a tiny
operation cap: one proves the analyzer trips from inside the nested body,
the other uses `with_analyzer` to prove the type checker trips from the
recursive `check_statement_types` poll onto the `budget_error` channel.

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

* fix: make a type-check budget breach a fatal typed result, not a side channel

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

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

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

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

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

* perf: collect pattern input once, checkpoint before preprocessing

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

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

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

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

* fix: hold the HTTP admission slot until the response completes

Moving the in-flight admission guard into the queued `WflHttpRequest` fixed
timed-out bodies that were still queued, but once the interpreter dequeued a
request, `WaitForRequestStatement` moved only `response_sender` into
`pending_responses` and dropped the local request (and its guard) when the
statement returned. So the global admission gate reopened at dequeue: a
handler could dequeue requests in a loop without responding, immediately
readmitting new work while every route task and pending sender stayed
unfinished until its timeout.

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

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

* fix: guarantee a WebSocket Disconnect for every admitted Connect

The connect event is fail-closed, so every live connection has run (or will
run) its `on websocket connect` handler, which may initialize per-connection
application state. The disconnect event — the only place that state is
cleaned up — used a lossy `try_send`, so a momentarily-full event queue could
drop it, leaking application state and leaving a queued Connect with no
matching Disconnect.

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

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

* fix: scope the run budget task-locally so interleaved runs cannot cross-contaminate

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

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

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

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

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

* fix: budget-checkpoint the lexer and recursive expression parsing/analysis

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

Add strided/recursive checkpoints across the front end:

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

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

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

* fix: expose the large interpreter stack as a public helper for embedders

The interpreter's async recursion is stack-heavy (~1 MiB per WFL call), so its
`max_call_depth` only turns runaway recursion into a clean, catchable error on
a stack large enough to reach the limit. The 1 GiB stack was set up privately in
the CLI's `main`, so a library embedder driving `Interpreter::interpret`
directly on an ordinary 8 MiB thread with the default `max_call_depth` (1000)
could crash the host process with a native stack overflow that no depth limit
can catch.

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

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

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

* docs: Dev Diary entry for the fifth review round (maintainer P1s)

Document the seven fixes landed for the maintainer's fourth-pass review:
typed fatal type-check result, pattern collect-once, HTTP admission lifetime,
guaranteed WebSocket disconnect, task-local budget scoping, lexer/expression
front-end checkpoints, and the public large-stack embedder helper.

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

* fix: address issue #611 — lexer typed outcome, HTTP admission release, and three residuals

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

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

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

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

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

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

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

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

* fix: consult the run budget at the lexer boundary, not only on a full stride

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
logbie pushed a commit that referenced this pull request Jul 24, 2026
Brad's re-review: `write line|chunk` cloned the entire text/binary value into a
new `Vec` and only then checked it against `web_server_max_response_size` — so a
huge write allocated/copied far past the limit just to be rejected.

Now compute the outgoing byte length from the value in place (`str::len` /
`[u8]::len`, plus the optional newline; numbers/booleans render to a short
string), reserve the response-byte budget under the map borrow, and only
materialize the bytes once the write is known to fit. Over-budget writes are
refused before any large allocation; the stream is still dropped/untracked on
breach as before.
logbie added a commit that referenced this pull request Jul 25, 2026
…rs (#641)

* feat: outbound HTTP response streaming (stream response / wait for next chunk|line)

Add generic outbound response streaming to the WFL client so a large or
progressively-emitted upstream body can be consumed without buffering:

  open url at "<url>" [with method .. and headers .. and body ..]
      and stream response as upstream
  wait for next line  from upstream as line   // Text, or nothing at EOF
  wait for next chunk from upstream as chunk   // Binary, or nothing at EOF
  close upstream                               // cancels the upstream request

`stream response as` returns as soon as the status/headers arrive, without
buffering the body, binding an object with status/ok/headers and an internal
stream id. The body is parked in a new IoClient stream-handle table and pulled
incrementally; each read takes the handle out of the map so a slow read never
blocks other streams.

Lifecycle (client side): the head phase and every per-chunk read go through the
existing run_http_with_budget select, so connect/read timeouts, the
response-byte ceiling (enforced on the running total, not just Content-Length),
and cooperative cancellation all apply. Mid-stream network errors are catchable
RuntimeErrors; dropping the handle (EOF, error, close, teardown) cancels the
upstream; reading a closed/drained handle is a predictable error. A final
unterminated line is delivered before a single clean `nothing` at EOF.

- AST: HttpStreamStatement, WaitForNextChunkStatement, WaitForNextLineStatement
- Parser: `stream response as` clause (io.rs); `wait for next chunk|line from`
  (processes.rs); no new lexer tokens (contextual identifiers)
- Interpreter: IoClient open_http_stream/next_chunk/next_line/close_stream;
  three statement arms; `close` extended to accept a streaming-response object
- Analyzer/typechecker/transpiler: variable-binding registration; explicit
  unsupported-in-JS-transpilation arm
- Docs: interoperability.md "Streaming a response incrementally" + validated
  example; Dev Diary entry
- Tests: tests/http_stream_test.rs (parser + offline runtime, incl. EOF and
  closed-stream error)

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

* docs: lock design for streamed server responses (item 3) and streaming roadmap

Capture the turnkey design for server-side response streaming and the overall
response-streaming roadmap, so the next increment is unambiguous:

- Chosen surface: `start streaming response to <req> with status .. and content
  type .. as <out>`, `write line|chunk <expr> to <out>`, `flush <out>`,
  `close <out>` — consistent with the shipped client streaming and dispatched
  like the existing `send websocket message` leading-identifier statements.
- Mechanism: a HandlerReply enum (Buffered | Streaming{status, content_type,
  headers, body: mpsc::Receiver}) over the existing per-request oneshot; the
  warp route's final closure converts to a Body-typed reply (Body::from for the
  buffered/504 arms, Body::wrap_stream(unfold(rx)) for the streaming arm) while
  the recover helper stays Vec<u8> (warp unifies via Either) — so only the final
  handler closure changes, not the five reply helpers.
- Lifecycle: bounded mpsc for backpressure; a closed receiver (browser
  disconnect) makes `write` fail catchably, which the handler uses to close the
  upstream; `close`/drop ends the body; writes after close fail predictably.

Also records status of all five requested capabilities: items 1-2 and the
client half of item 5 shipped; item 3 designed; item 4 deferred to the locked
concurrency-phase-plan (Phase 1 `main loop concurrently:`).

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

* feat: streamed server responses (start streaming response / write line|chunk / flush / close)

Add server-side response streaming so a WFL handler can send status/headers
immediately and produce the body progressively, without buffering:

  start streaming response to req with status 200 and content type "application/x-ndjson" as out
  write line json_text to out    // frames a line (newline appended)
  write chunk raw_bytes to out    // raw bytes/text, verbatim
  flush out                       // advisory
  close out                       // ends the response body

Combined with the client-side `stream response as upstream` + `wait for next
line`, a handler can proxy a slow upstream to the browser line-by-line.

Mechanism:
- The per-request oneshot now carries a HandlerReply enum
  (Buffered(WflHttpResponse) | Streaming{status, content_type, headers,
  body: mpsc::Receiver}). `respond` sends Buffered; `start streaming response`
  sends Streaming with a bounded body channel's receiver.
- The warp route's final closure returns a Body-typed reply. warp's recover
  unifies reply types via Either, so only that closure changed: it wraps the
  buffered/504 arms with Body::from and builds the streaming arm with
  Body::wrap_stream(unfold(rx)) (no new dependency); the five Vec<u8> reply
  helpers are untouched.
- Backpressure: the body channel is bounded (64) so a slow client backpressures
  `write`. Disconnect: hyper drops the body -> receiver dropped -> the next
  `write` fails with a catchable error, propagating the disconnect to the
  handler (which can close any upstream it proxies). `close`/handler-exit drops
  the sender, ending the response.
- `start` is a keyword; `streaming`/`flush`/`line`/`chunk` are identifiers.
  `close` now also closes a server response stream (`_server_stream`).

- AST: StartStreamingResponseStatement, StreamWriteStatement, FlushStreamStatement
- Parser: parse_start_streaming_response/parse_flush_stream (web.rs), write
  line|chunk branch (io.rs), KeywordStart/flush dispatch (parser/mod.rs)
- Interpreter: server_response_streams map, three exec arms, close extension
- Analyzer/typechecker/transpiler arms
- Docs: web-servers.md "Streaming a response" (+ upstream-proxy example);
  response-streaming-design.md status; validated example; Dev Diary
- Tests: tests/http_server_streaming_test.rs (parser + end-to-end streaming
  server read back with reqwest); existing web-server suites still green

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

* feat: concurrent request handlers (main loop concurrently:)

Add opt-in cooperative concurrent request handling so a slow handler (e.g. one
proxying a slow upstream stream) no longer blocks other requests:

  main loop concurrently:
      wait for request comes in on server as req
      ... slow handler ...
      respond to req with "..."
  end loop

Plain `main loop` stays strictly serial and byte-compatible; `concurrently` is
the only opt-in and nothing changes silently. This is Phase 1 of the
maintainer-locked concurrency-phase-plan.md (locked marker, no Rc->Arc/Send
rewrite of the interpreter core) and lands at that plan's STOP/review gate.

Mechanism:
- AST: MainLoop gains `concurrent: bool`. `concurrently` is a contextual
  identifier (only special right after `main loop`), so it stays usable as a
  variable name elsewhere.
- execute_concurrent_main_loop keeps up to CONCURRENT_HANDLER_LIMIT (256) body
  iterations in flight via a FuturesUnordered of !Send, non-'static,
  &self-borrowing handler futures — cooperative concurrency on the single
  interpreter thread (no spawn_local, no Send/Arc across the core). Each
  iteration runs in a fresh isolated child scope. Cap >= 1 keeps the set
  non-empty, avoiding the Ready(None) busy-spin trap.
- Containment: each handler future is AssertUnwindSafe(...).catch_unwind(); a
  panicking or erroring handler is contained and its request is answered 500 by
  the existing ResponseCompletion drop guard while siblings keep running.
- 503/504/500 are inherited from the existing transport layer (bounded queue,
  response deadline, drop guard); `wait for request` releases the receiver lock
  before the handler runs, so iterations hand off requests one at a time then
  handle them concurrently. No RefCell held across await (crate-wide
  await_holding_refcell_ref deny enforces this).

- Docs: web-servers.md "Concurrent request handling" (concurrent != parallel,
  yield cliff, 503/504/500) + validated example; concurrency-phase-plan.md
  tracker updated to Phase-1-done/awaiting-review; Dev Diary.
- Tests: tests/concurrent_main_loop_test.rs — concurrently parses vs serial;
  slow handler does not block fast sibling; serial DOES block (no silent
  upgrade); handler-error containment keeps the server serving.

Also removes two .ast.txt parse-dump artifacts accidentally added in the two
prior streaming commits.

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

* fix: address PR #641 review — backward-compat, status validation, doc honesty, test hygiene

Automated review (CodeRabbit/Copilot/Devin/Codex) on PR #641 surfaced several
issues; this fixes the clear ones:

Backward-compatibility regressions (parser):
- `write <var> to <file>` with a variable literally named `line`/`chunk` was
  wrongly intercepted as a stream write and failed to parse. Only treat
  `line`/`chunk` as the stream marker when it is NOT immediately followed by
  `to`. Regression test added.
- `wait for next <unit>` (e.g. `wait for next milliseconds`) with a variable
  named `next` was wrongly intercepted as `wait for next chunk|line`. Only take
  the bare-`next` path when `chunk`/`line` actually follows. Regression test
  added.

Correctness / consistency:
- `start streaming response ... with status <n>`: require a whole number in
  100..=599 instead of silently wrapping via `as u16`.
- `FlushStreamStatement` is now classified async in the transpiler's
  `stmt_is_async` (it yields), consistent with the other streaming statements.

Docs honesty (CLAUDE.md "Docs Must Be Honest"):
- Removed the false claim that a streamed server response is "closed
  automatically when the handler ends"; the handler must `close out` (documented
  with a finally: recommendation). Client-side wording likewise corrected: a
  stream is released on EOF/error/explicit close/program exit, not on handle
  drop.
- Concurrent example + phase plan: state the cooperative limitation (a CPU-bound
  handler with no await still holds the thread), and separate tested
  runtime-error containment from by-construction catch_unwind panic containment.
- Escaped the `chunk|line` pipe in a Markdown table (MD056).

Test hygiene (concurrent_main_loop_test):
- Moved off ports that collided with another test (8241 -> 8341..8343).
- Each server now exposes `/shutdown` (close server + break); tests send it and
  join the server thread deterministically.
- Assert the slow request actually completed instead of discarding its result.

Also flags in concurrency-phase-plan.md a known gap for maintainer review: the
concurrent loop isolates the environment but not interpreter-level run-state
(count/call-stack), which needs a per-handler execution context.

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

* ci: skip non-executable streaming docs examples in run_integration_tests

The integration runner executes every TestPrograms/*.wfl, but the three new
docs-examples need a live upstream / HTTP clients (or run a server loop forever),
so they fail/timeout when run standalone. They are validated statically via the
docs-examples manifest (layers 1-4). Add the runner's first-line `CI-SKIP:`
directive to each so they are skipped by run_integration_tests.sh while still
being parse/analyze/lint-validated.

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

* docs: adopt Logbie Testing Policy (root testing.md) + WFL testing profile

Add the binding Logbie Testing Policy v1.0 as root `testing.md` (required by the
policy's §4), together with the WFL project testing profile: supported
platform/runtime tuples, one command per test layer + a run-all-presubmit block,
critical user/operator journeys, risk triggers (esp. §11.3 concurrency/streaming/
lifecycle for this repo's async work), coverage targets, CI gating, owners,
justified N/A layers, and tracked conformance gaps.

Wire it into the agent guides: CLAUDE.md and AGENTS.md now list `testing.md` in
the governance table and summarize the non-negotiables — Red→Green TDD evidence,
risk classification (async/streaming/lifecycle = R3), real-boundary tests with
meaningful/negative assertions, no manufactured-green, and PR evidence (§15).

Risk class: R0 (documentation/policy; no executable behavior changes).

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

* fix: streaming bindings use define_or_replace (loop re-reads + no bind-collision leak)

Address PR #641 review (P1 #8 + Copilot bind-collision leak).

`wait for next chunk|line as <name>` and the `open ... stream response as <name>`
/ `start streaming response as <name>` object binds used `Environment::define`,
which errors if the name already exists in scope. Re-reading into the same
variable in one scope (or a recycled `main loop` env) therefore failed on the
second read — the normal streaming loop pattern. Switch all streaming bindings to
`define_or_replace`, matching `wait for request ... as req`.

This also removes the bind-collision leak Copilot flagged: `start streaming
response` committed the response head + stored the body sender before binding the
handle; a binding failure left an unreachable open stream (client hang + leaked
sender). With define_or_replace the bind cannot fail after the head is sent.

Risk class: R2 (streaming boundary behavior). Red→Green evidence: added
`test_next_line_reusing_same_variable_in_one_scope` (observed failing with
"Variable 'line' has already been defined" before the fix; passing after) plus
`test_next_line_count_loop_reusing_same_variable`. Full http_stream_test suite
green (12/12); clippy clean.

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

* fix: address PR #641 review — transpile-fail, streaming type checks, response byte budget

Contained hardening from maintainer review:

- transpiler: `main loop concurrently:` now returns a TranspileError instead of
  silently emitting a serial `while(true)` loop (no faithful serial translation
  exists). Plain `main loop` still transpiles.
- typechecker: enforce clause types on `start streaming response`
  (status: Number, content type: Text, headers: Map<Text, Any>) and clarify the
  HTTP-body message (numbers/booleans are accepted and converted).
- interpreter: `write line|chunk` on a server response stream now enforces the
  `max_response_bytes` ceiling, so a stream cannot bypass the buffered-response
  budget. Byte total is tracked per open stream.
- parser: `flush` only dispatches as a flush statement when it has an operand.
- interpreter: stream handles must be the handle object (`_stream` /
  `_server_stream`); a bare Text id is no longer accepted.

Tests: write-after-close does not reach the client; concurrent main loop fails
to transpile.

* fix: isolate per-handler run-state under `main loop concurrently:` (P1 #1)

The concurrent loop isolated each handler's environment but interpreter
run-state (current_count/in_count_loop, call_depth, call_stack, and the block
overload-dup set) still lived on the shared `Interpreter`. Two concurrent
handlers interleaving on the single thread could overwrite each other's
count-loop/recursion/call-stack bookkeeping across an await — e.g. one count
loop reading a `count` set by another handler.

Fix without touching the single-threaded Rc core: each handler carries its own
`RunState`, and an `IsolatedHandler` poll wrapper swaps that state into the
interpreter only for the duration of each poll, swapping it back out the instant
poll returns (ready or pending). The run-state fields become poll-local; a
suspended handler's state is parked in its own `RunState` where no sibling can
touch it. Serial execution is untouched.

Red→Green: tests/concurrent_main_loop_test.rs::
test_concurrent_handlers_do_not_share_count_loop_state — two handlers count over
disjoint ranges (1..5 vs 100..104), yielding mid-loop. Red (isolation bypassed):
/a returned 100-101-102-103-104- (the other handler's range). Green: /a returns
1-2-3-4-5-.

Also from review:
- Fix stale doc comments on resolve_stream_handle / resolve_server_stream_handle
  (they require the handle object; a bare Text id is rejected — doc now matches).
- Docs: web-servers.md disconnect note uses try/catch (consistent with examples).

* fix: auto-close server response streams on handler exit (spec item 5; Devin/Copilot)

A streamed server response (`start streaming response`) parked its body-channel
sender in the long-lived `server_response_streams` table. The sender was dropped
only by an explicit `close out` or the write-after-disconnect path, so a handler
that ended without `close out` (normal return, caught error, break) left the
sender in the table forever: the client's chunked body never terminated (client
hangs) and the table leaked one dead entry per streamed request. This
contradicted the shipped docs/design and the streaming spec's item-5 lifecycle
guarantee ("all streams close on every exit path").

Fix: each handler tracks the respstream* ids it opens in per-handler run-state
(`open_response_streams`, part of the RunState swapped per poll), and closes them
when it ends on ANY path:
- concurrent handlers: IsolatedHandler's Drop (covers return/error/panic/cancel);
- serial main loop: drain after each iteration (normal and error paths);
- top level: drain at program exit.
close_response_streams is idempotent, so explicit `close out` (still preferred,
finalizes promptly) and auto-close compose safely.

Red→Green: tests/http_server_streaming_test.rs::
test_stream_auto_closes_when_handler_ends_without_close — handler omits
`close out`; client reads body under a 5s timeout. Red (drain disabled): body
never finishes (Elapsed). Green: reads "hello\n".

Also from review:
- typechecker: reword the HTTP-body type error to list accepted types (text,
  number, boolean) instead of the self-contradictory "must be text (…)".
- docs: web-servers.md, response-streaming-design.md, and the server-streaming
  dev diary now describe the shipped close-on-exit behavior; new dev diary entry.

* fix: 500 immediately when a handler ends without responding (P1 #3)

The ResponseCompletion drop guard only arms once a handler reaches a
respond/start-streaming statement. A handler that dequeues a request
(`wait for request comes in`) and then ends before responding — runtime error,
break, or a plain return — left the sender parked in pending_responses, so the
client waited out the request timeout instead of getting a prompt 500.

Fix: each handler tracks the request ids it dequeued but hasn't answered in
per-handler run-state (`open_pending_requests`, part of the RunState swapped per
poll). `respond`/`start streaming response` remove the id from the map and
disarm tracking; on handler exit (any path) `fail_unanswered_requests` 500s any
id still in pending_responses. Fully synchronous (try_lock + oneshot send) so it
runs from IsolatedHandler's Drop; also wired into the serial main loop's
per-iteration drain and program exit. Idempotent — a responded request is gone
from the map, so the sweep skips it.

Red→Green: tests/concurrent_main_loop_test.rs::
test_handler_that_never_responds_gets_immediate_500 — /drop dequeues and returns
without responding. Red (sweep disabled): the request hangs past the 120s test
timeout. Green: 500 arrives in <1s and the server keeps serving.

Also from review:
- The response-byte-ceiling and disconnect paths untrack the stream id from
  open_response_streams so a handler that catches the error keeps no stale ids.
- typechecker: HTTP/response header type hints widened to map[text, any].

* fix: `write line/chunk <var> to <file>` keeps the classic file write (back-compat)

`write line <value> to <out>` shares a surface with the classic file write
`write <content> to <file>`, and WFL identifiers can be space-separated, so the
lexer merges `line payload` into one token. The parser unconditionally split it
into a `line` marker + value, silently reinterpreting a pre-existing file write
of a variable named `line payload` as a stream write — a backward-compat break
(flagged by Copilot).

The two readings can't be told apart at parse time (both the NDJSON stream write
and the file write use a bare variable), so disambiguate on the runtime target
type:

- AST/parser: StreamWriteStatement gains `fallback_content` — for the ambiguous
  merged form it records both the stream value (`payload`) and the classic
  file-write content (`line payload`); unambiguous forms set None.
- interpreter: evaluate the target first; if it is a server response stream, do
  the stream write, else if a fallback exists do the classic file write, else
  error.
- analyzer: defer definedness for the ambiguous form (the live reading, and thus
  which variable must exist, is only known at runtime); count both candidate
  variables as used so neither is falsely reported unused.

Red→Green: tests/write_line_backcompat_test.rs — `store line note as "…"` /
`write line note to "<file>"`. Red (analyzer analyzing the stream value): rejects
with `Variable 'note' is not defined`. Green: analysis accepts it and the file
receives the variable's value, not the token `note`. Existing streaming tests
(`write line "alpha" to out`, bare `write line to out`) still pass.

Docs: web-servers.md notes the target-type disambiguation.

* docs: reconcile concurrency/testing status with shipped reality (review)

Address doc-consistency review comments:
- concurrency-phase-plan.md: qualify per-request isolation (environment + now
  run-state; global bindings/shared collections stay shared by design); state
  500 containment precisely (transport ResponseCompletion mid-respond, plus the
  interpreter's immediate 500 when a handler ends without responding); sync the
  stale PR-1b TODO checklist to the as-shipped state ([x] done, [~]
  transport-provided, and the two remaining known gaps — request-ID logging and
  a dedicated panic-containment test — called out explicitly).
- testing.md: add an adoption note so the verbatim "Status: Proposed / Effective:
  Upon adoption" policy block reads consistently with this repo's header and
  CLAUDE.md/AGENTS.md (adopted, binding, effective 2026-07-22); correct the
  supported-tuples table — CI has no macOS runner (only ubuntu + windows), so
  macOS is best-effort/not-gated, not "release smoke only".

Docs-only; no behavior change.

* fix: parse `content type <var>` in start-streaming; tighten review tests/docs

- parser: `start streaming response ... and content type <var>` where <var> is a
  bare identifier merges into `type <var>`; split the marker so the variable is
  bound correctly (previously bound `type <var>` as one name). Regression test.
- tests: `write chunk` fallback parser coverage (ambiguous + literal forms);
  transpiler test asserts the specific unsupported-transpilation message rather
  than any error; write-line backcompat test uses an isolated TempDir per run.
- docs: web-servers.md no longer over-specifies chunked transfer-encoding
  (HTTP/1.1 only) — describes the observable behavior (no Content-Length,
  streamed incrementally) with the HTTP/2 note.

* docs: qualify concurrent-handler isolation wording in example (review)

Replace 'own isolated scope' with the precise contract: per-iteration child
scope + per-handler run-state isolation (so per-request `store` variables don't
clobber), while global bindings and collections shared through them stay shared
by design. Matches the concurrency-phase-plan wording.

* test: wait for server readiness instead of a fixed sleep (fix flaky CI)

CI "Build, Test, Clippy" failed on concurrent_main_loop_test with `Connection
refused`: the tests slept a fixed 300ms after starting the server thread, but on
a loaded CI runner binding the port can take longer, so the client connected
before the server was ready. Per the testing policy a flaky required test is a
failing test.

Replace the fixed sleep with `wait_for_server(port)` — a bounded readiness probe
that TCP-connects until the server accepts (a bare connect that drops
immediately delivers no HTTP request to the handler). Applied to both
concurrent_main_loop_test and http_server_streaming_test. Deterministic and
faster (returns as soon as the port is bound).

* fix: mark streaming-statement vars used in analyzer; tidy body type hint & docs

- analyzer: `mark_used_variables` now covers HttpStreamStatement,
  WaitForNextChunk/LineStatement, and StartStreamingResponseStatement — so
  variables referenced only in those (URL/body/headers, stream source,
  request/status/content-type) are no longer falsely reported unused (Copilot).
  Regression test in static_analyzer.
- typechecker: the HTTP-body type error passes no single "expected" type (the
  accepted set is Text|Number|Boolean), so the expected-vs-actual diagnostic
  isn't misleadingly rendered as "expected Text" (Copilot).
- docs: web-servers.md clarifies that only the ambiguous bare-identifier
  `write line/chunk` form falls back to a file write; literal/number/boolean
  forms are stream-only and error on a non-stream target (Copilot).

* test+docs: broaden streaming unused-var test; qualify phase-plan claims (review)

- static_analyzer test: cover every new streaming arm (HttpStream url/method/body,
  WaitForNextChunk + WaitForNextLine source, StartStreamingResponse status/content
  type) and metadata operands, with a negative assertion that a genuinely-unused
  variable is still the only one flagged (CodeRabbit).
- concurrency-phase-plan.md: narrow the "tested" 503/504/500 claim to the
  immediate-500 case that actually has a dedicated test (503/504 are
  transport-provided, untested at Phase 1); mark the eval-core RefCell-across-await
  audit as OPEN/partial — the clippy backstop covers await-holding borrows but the
  shutdown/signal lifecycle paths are not yet separately audited; qualify the 1c
  tracker status to match the open checklist items (CodeRabbit).

* docs: align write-chunk value types, note write-line `with` limit, drop dead var

- web-servers.md: `write chunk <value>` also accepts numbers/booleans (written as
  their text form), matching the interpreter; add a note that `write line/chunk`
  don't yet accept in-statement `with`-concatenation on a bare-variable value
  (workaround: build the value with `store` first).
- interoperability.md: remove the unused `store done as no` leftover from the
  incremental-read example.

Doc-only; matches shipped behavior. (Copilot)

* fix: drop RefCell borrows before await; re-enable await_holding_refcell_ref deny (P1 #4)

Brad's re-review: `close server` held `web_servers.borrow_mut()` across a 50ms
await, and the whole module suppressed `clippy::await_holding_refcell_ref` with a
crate-level allow — so a sibling handler touching the map during that yield could
panic, and the lint could not catch future regressions.

- `close server` (HTTP and WebSocket): remove the entry into a local and drop the
  map borrow BEFORE the graceful-shutdown await.
- Parent-method call: clone the parent `Rc`/type out of the container instance so
  no instance/parent RefCell borrow spans the awaited method call.
- Two `open file for reading` paths: capture the `env.define` result and drop the
  env borrow before the `close_file` await.
- Flip the module attribute from `allow` to
  `#![deny(clippy::await_holding_refcell_ref)]` so borrow-across-await is now a
  hard error for this module going forward.

clippy --all-targets clean; streaming/concurrent tests green.

* fix: budget-check server write size before materializing bytes (P1 #6)

Brad's re-review: `write line|chunk` cloned the entire text/binary value into a
new `Vec` and only then checked it against `web_server_max_response_size` — so a
huge write allocated/copied far past the limit just to be rejected.

Now compute the outgoing byte length from the value in place (`str::len` /
`[u8]::len`, plus the optional newline; numbers/booleans render to a short
string), reserve the response-byte budget under the map borrow, and only
materialize the bytes once the write is known to fit. Over-budget writes are
refused before any large allocation; the stream is still dropped/untracked on
breach as before.

* fix: finalize top-level streams/requests on every exit path (P1 #5)

Brad's re-review: "all streams close on every exit path" was false outside
ordinary completed loop iterations.

- Cleanup now runs AFTER the conventional `main` action (it ran before), so a
  stream/request opened by `main` is finalized instead of leaking.
- The mid-run per-statement timeout early-return now drains open streams and 500s
  unanswered requests before returning (previously bypassed cleanup).
- On interpreter reuse (REPL), the reset now actually CLOSES the prior run's open
  streams and 500s its unanswered requests (draining the tracking) instead of
  merely clearing the id lists — clearing alone stranded the still-open
  sender/receiver in the maps, hanging the client and leaking the entry.

Streaming/concurrent tests green.

* fix: only dispatch `start` to streaming when `streaming` follows (back-compat)

Brad/Copilot: statement dispatch routed every `Token::KeywordStart` to
`parse_start_streaming_response()`, which errors unless the next token is
`streaming`. That would hijack any other statement-initial use of the `start`
keyword (e.g. a `start of text` pattern anchor). Guard the arm so it only fires
when `streaming` actually follows; otherwise `start` falls through to its normal
handling. `start streaming response ...` still parses.

* fix: back off and cap consecutive failures in concurrent main loop (P1 #3)

Brad's re-review: the concurrent loop refilled and re-polled with no
backoff/classification, so a deterministic pre-await error (bad expression) or
`wait for request` on a server closed without the loop breaking would hot-spin
the CPU and the log forever — and the loop is deadline-exempt.

Track consecutive handler failures (reset on any successful iteration). Between
failures, back off with a small capped sleep so instant failures yield instead
of spinning; once failures cross a structural-failure threshold (256 with no
progress), terminate the loop rather than refilling forever. Incidental handler
errors interleaved with successful requests never trip the guard.

* feat: absolute total deadline for outbound streaming responses (P1 #2)

Brad's re-review: each outbound `stream_pull` got a fresh idle timer, so an
upstream that trickles a byte before every idle timeout could run forever inside
the deadline-exempt main loop.

Add an absolute total-lifetime cap distinct from the per-read idle timeout:
- new config `outbound_stream_max_seconds` (default 300; `0` disables), with a
  config-reference entry;
- HttpStreamHandle records a `total_deadline` at open time;
- `stream_pull` refuses a read once the total deadline passes AND bounds each
  read's idle timeout by the time remaining to the total, so no single read
  waits past the cap and a trickling upstream cannot outlive it.

config/http_stream tests green; clippy clean.

* ci: gate docs-validation and web tests; make scripts executable (P1 gating)

Brad's re-review: testing.md claims CI runs web tests and docs validation, but
ci.yml invoked neither, and run_web_tests.sh / validate_docs_examples.py were
committed non-executable (mode 100644) — so green CI didn't supply the R3
web/streaming boundary evidence the policy now mandates.

- Mark both scripts executable (git +x).
- Integration Tests job now runs `validate_docs_examples.py --ci` and
  `run_web_tests.sh` on Linux (after the release binary is built). The Rust
  streaming/concurrent integration tests already run via `cargo test --test '*'`;
  these add the doc-example and real-web-server gates.

* test: surface server-thread interpret errors and re-raise join panics

Copilot: the streaming/concurrent server threads ignored `interpret()`'s result
and the tests ignored `join()`, so a server-side interpreter error or panic was
dropped and the test could still pass.

- Server threads now `panic!` on an unexpected interpreter error.
- `join_server` (streaming) and `shutdown` (concurrent) `resume_unwind` the
  thread's panic payload (JoinHandle::join's error is Box<dyn Any>, no Debug), so
  a server-side failure fails the test loudly with its original message.

All streaming/concurrent tests still pass (confirming clean shutdown on Ok).

* fix: tighten ambiguous write line/chunk analysis

Analyze the unambiguous of-object subexpression of a merged
`write line|chunk <ident> to <target>`, and report an undefined
variable only when NEITHER candidate name resolves (stream value
<ident> vs classic file-write variable `line <ident>`), so a genuine
typo is still caught without breaking either valid reading.

Addresses Copilot review comment on src/analyzer/mod.rs.

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

* fix: require wait-for-request first in concurrent loop; enforce text header keys

Three review-driven hardening changes on the streaming branch:

- Analyzer: a `main loop concurrently:` body must begin with `wait for
  request`. Concurrent handler slots start from the top of the body, so any
  statement before the first `wait for request` runs once per slot before a
  request is dequeued. Reject that at analysis time with an actionable error
  instead of running setup speculatively. Serial `main loop` is unaffected.
  (New surface; every example/test already complies.)

- Typechecker: HTTP header maps must have text keys. A single
  is_valid_header_map_type helper (used by outbound HTTP, streaming responses,
  and `respond ... and headers`) rejects a map with a concrete non-text key
  while accepting text-keyed and loosely-typed maps, matching the "header
  names" contract in the error message.

- CI: run docs-example validation and the web-server suite on Windows too
  (run_web_tests.ps1), and pass --force so docs validation ignores the
  committed cache and always re-validates.

Red->Green evidence: test_concurrent_main_loop_requires_wait_for_request_first
and test_header_map_type_requires_text_keys, both confirmed failing without the
change and passing with it. Docs + Dev Diary updated.

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

* docs: restore report_undefined_name doc comment to its function

The analyze_loop_body extraction inserted the new function between
report_undefined_name's doc comment and its definition, orphaning the doc
above the wrong function. Move it back so each function documents itself.

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

* fix(ci): give Start-Process distinct stdout/stderr targets in web test runner

Enabling the Windows web-server test gate surfaced a pre-existing bug in
run_web_tests.ps1: all three Start-Process calls passed "NUL" for both
-RedirectStandardOutput and -RedirectStandardError, which PowerShell 7 rejects
("RedirectStandardOutput and RedirectStandardError are same"), failing the job
before any test ran. Redirect the discarded server output to two distinct
temp files instead (the TLS case uses its already-cleaned temp dir).

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

* fix(ci): address WFL server on 127.0.0.1 (not localhost) in Windows web tests

The Windows web-test gate got past the redirect-collision fix and revealed the
real cause of the timeouts: WFL binds 127.0.0.1 (IPv4 only) by default, but the
runner addressed the server as http://localhost. On Windows, localhost resolves
to IPv6 ::1 first, so Invoke-WebRequest never connected and every readiness
probe/request timed out. The Linux .sh runner uses curl, which resolves
localhost->127.0.0.1, so it never hit this. Address the server by 127.0.0.1
everywhere (the redirect Location mirrors the Host header, so its expected value
changes to match; the self-signed cert CN is irrelevant under
-SkipCertificateCheck).

Also per maintainer review, to make the gate trustworthy/debuggable:
- Dump the server's captured stdout/stderr on every failure path (Show-ServerLogs)
  so a real server error is not hidden behind a bare TIMEOUT.
- Kill AND WaitForExit before removing the TLS temp dir (Stop-ServerProcess) to
  avoid a Windows cleanup race on the cert/log file handles.
- Wrap the route-test requests so a request failure fails that test (and dumps
  logs) instead of throwing out of the script and skipping the summary.

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

* fix(ci): deterministic flush + monotonic deadline + exit code in web tests

Addresses the maintainer's remaining Windows-harness recommendations (the
localhost->127.0.0.1 address-family fix and Kill/WaitForExit landed in 59d4809):

- simple_web_test.wfl: add `close server test_server` after the one-shot
  respond. `respond` hands the reply to the async transport, so reaching EOF
  can tear the runtime down before the socket flushes; `close server` has a
  short grace that drains the pending response first, making single-shot
  delivery deterministic.
- run_web_tests.ps1: replace the fixed retry-count readiness loops with a
  Stopwatch wall-clock deadline. The old '20 tries' loop could run ~50s for a
  '10s' wait (each failed 2s request stacked on the 500ms sleep); the deadline
  caps total wait at the advertised timeout.
- run_web_tests.ps1: Show-ServerLogs now also reports the child process exit code
  (or that it was still running), so a failure shows the decisive server-side
  evidence, not just its stdout/stderr.

Verified simple_web_test.wfl still parses and analyzes cleanly.

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

* fix: robust redirect Location extraction; exact streaming dispatch; header value types

Three review-driven fixes:

- run_web_tests.ps1 (Windows CI): the 127.0.0.1 fix got 2/3 web tests passing;
  the TLS redirect assertion still failed with an empty Location because pwsh 7
  throws on -MaximumRedirection 0 and $_.Exception.Response is an
  HttpResponseMessage whose Headers has no string indexer (so ["Location"] is
  $null). Add Get-LocationHeader that reads the strongly-typed
  HttpResponseHeaders.Location (Uri) for that shape and the case-insensitive
  string indexer otherwise.

- parser (Copilot): restrict the `start streaming response` dispatch to the
  EXACT Identifier("streaming") token instead of starts_with("streaming ").
  `response` is a keyword so `streaming` never merges; the prefix form would
  hijack an unrelated `start streaming <ident> ...` into a confusing parse
  error.

- typechecker (Copilot): is_valid_header_map_type now also validates the map
  VALUE type. The interpreter accepts only text/number/bool header values
  (stringified) and rejects the rest, so Map<Text, Binary> (etc.) is flagged;
  Unknown/Any/Error keys and values still pass to avoid false positives.

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

* fix: parse with-continuation in write line/chunk; harden Windows web runner

Backward-compat (maintainer review): the merged `write line|chunk <ident>`
form expected `to` immediately after the identifier value, so a `with`/operator
continuation was rejected — breaking `write line payload with "!" to out` AND
the pre-existing classic file write `write line payload with "!" to file`.
Expose parse_binary_continuation (the operator loop of parse_binary_expression)
and use it to absorb the continuation onto the stream reading, then mirror it
onto the classic file-write fallback by swapping the leftmost leaf operand
(replace_leftmost_leaf). Both readings now carry the full expression.

Windows web runner hardening (maintainer review):
- Get-LocationHeader matches the HttpResponseMessage shape by type NAME, not the
  [System.Net.Http.HttpResponseMessage] type literal, which a clean PowerShell
  5.1 process could fail to resolve before the fallback runs.
- Stop-ServerProcess checks WaitForExit(5000)'s result and only reports
  "terminated" when the process actually exited (else warns), instead of always
  claiming success while a live process races temp-file cleanup.

Tests: write_line_backcompat_test gains a parse test (continuation mirrored to
both readings) and a runtime test (classic file write preserves the
concatenation). Full parser suite + clippy green.

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

* docs: write line/chunk now supports with-concatenation directly

The continuation-parsing fix makes the write value a full expression, so
`write line prefix with json to out` works directly in the statement. Update
the web-servers guide, which still said with-concatenation was 'not yet
supported' and told users to build the value first — a now-stale claim that
contradicts the parser and its tests.

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

* fix: parse ambiguous write line/chunk readings independently; analyze continuation

Fixes a back-compat regression the leftmost-leaf approach introduced, plus the
coupled analyzer gaps (maintainer review).

Parser (P1 back-compat): the two readings of the merged `write line|chunk
<ident> ...` form are now parsed INDEPENDENTLY from the same tokens via cursor
checkpoint/rewind, instead of deriving the classic file-write AST from the
stream AST by swapping the leftmost leaf. A continuation desugars differently
per leading operand — a builtin name becomes an ActionCall, `is between`
duplicates the left operand, `starts/ends with` and pattern ops build calls —
so leaf-swapping silently dropped or mangled the continuation. Regression:
`store line length as "kept" / write line length with "!" to <file>` now
writes "kept!" (was "kept").

Analyzer: the ambiguous arm now analyzes the shared continuation (every
sub-expression except the ambiguous leading operand) so an undefined variable
there is caught, and reports the leading operand undefined only when NEITHER
reading resolves via a leftmost-leaf lead name — fixing both a false negative
(undefined RHS slipping through) and a false positive (valid classic program
defining `line <name>` rejected because the split stream name is undefined).

Docs: the web-servers note no longer claims the value is a "full expression";
it states that `with`/operators work but postfix (indexing/property) on the
leading identifier is not parsed there.

Tests: write_line_backcompat_test adds the builtin-named regression, an
undefined-in-continuation case, and a valid-classic-with-continuation case.

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

* fix(typechecker): accept stream handles in close, not just File

Maintainer review: stream handles (server response streams from `start
streaming response as <out>` and outbound streams from `... stream response as
<upstream>`) bind as map-shaped objects, but CloseFileStatement accepted only a
`File` object, so a valid `close out` / `close upstream` produced a spurious
"Expected a File object" diagnostic. Add is_closeable_type (File/Custom, the
map-shaped stream handle, or a statically-unresolved Unknown/Any/Error) and use
it; a concrete scalar like `close 5` is still rejected.

Tests (new stream_handle_type_test): closing a server response stream and an
outbound stream both type-check clean; closing a number still errors.

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

* fix(typechecker): only File custom type is closeable; drop misleading expected hint

Two Copilot review points on the close type-contract fix:
- is_closeable_type treated any Type::Custom(_) as closeable, so `close db`
  (Custom("Database")) / `close req` would wrongly pass. Restrict to
  Custom("File"); stream handles are Map-shaped (still accepted).
- The error says "file or stream handle" but passed Some(File) as the expected
  type, mis-rendering expected-vs-found. Pass None instead.

Test: closing a database handle is now rejected.

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

* fix(ci): guard Kill() and warn before racing TLS temp cleanup

Maintainer review (Windows-gate completeness): Kill() in Stop-ServerProcess was
outside the try, so a throwing Kill() (access denied / concurrent exit) went
unhandled; wrap it. And the TLS finally deleted the temp dir with
-ErrorAction SilentlyContinue even when the server had not actually exited,
hiding a failed cleanup — now re-check HasExited and warn that cleanup may be
incomplete before the Remove-Item, rather than silently proceeding.

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

* fix(typechecker): distinct stream-handle types so close rejects ordinary maps

Copilot review: is_closeable_type accepted any Map, so `close some_map` passed
even though the runtime can only close file/stream handles. Give stream handles
distinct static types instead of Map<Text,_>: the outbound handle is
Custom("HttpStream") and the server response stream is Custom("ResponseStream").
`close` now accepts only File|HttpStream|ResponseStream (plus Unknown/Any/Error);
an ordinary map, another custom type, or a scalar is rejected. IndexAccess on the
stream-handle types returns Any so reading their fields (status/ok/headers,
including nested header lookups) still type-checks; MemberAccess on Custom already
returned Unknown.

Tests: close of both handle kinds passes; closing an ordinary map, a database
handle, and a number are rejected; direct and nested field indexing still checks.

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

* style: rustfmt the stream-handle index-access match arm

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

* fix(analyzer): stop rejecting valid classic writes with desugared values

Fatal back-compat regression from the previous analyzer walk (maintainer
review): stream_write_lead_name/analyze_stream_write_continuation assumed the
ambiguous lead was always the leftmost leaf/function callee and that every call
argument or binary right branch was shared/unambiguous. Desugared values break
that — `starts/ends with` makes the lead a call ARGUMENT, `is between`
DUPLICATES the lead, and pattern/of/builtin-with bury it — so valid classic file
writes like `write line path starts with "/" to <file>` (only `line path`
defined) were wrongly reported undefined.

Analyze only the shapes where the lead is provably the single leftmost bare
variable — a bare Variable, or `<var> with <continuation>` (Concatenation with a
Variable left); for those, still catch an undefined shared RHS and flag the lead
only when NEITHER reading resolves. Every other (desugared) shape defers entirely
to runtime, so no valid program is rejected.

Tests: valid classic `starts with` / `is between` / `matches pattern` (and a
`write chunk` case) now analyze cleanly, while the undefined-RHS and
both-leads-undefined cases still error.

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

* fix(typechecker): support dot access on stream handles; require text index key

Follow-ups to the distinct stream-handle types (maintainer review):
- The canonical docs use dot access (upstream.status, upstream.headers[...]), but
  PropertyAccess only handled containers/maps/gradual types, so the new
  Custom("HttpStream")/Custom("ResponseStream") handle emitted a false
  "cannot access property" diagnostic. Add a stream-handle arm returning Unknown
  (runtime-known field type), mirroring the index-access and member-access paths.
- The stream IndexAccess arm returned Any without checking the key; a numeric key
  (resp[5]) wrongly passed even though runtime object indexing requires a text
  field name (Map<Text,_> rejected it before the nominal-type change). Require a
  text-compatible key.

Tests: dot access (incl nested header lookup) type-checks; a numeric stream-handle
key is rejected.

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

* fix(ci): surface TLS temp-dir cleanup failure instead of hiding it

Maintainer review (Windows cleanup completeness): the TLS finally deleted the
temp dir with -ErrorAction SilentlyContinue even when the server was still alive,
racing its open cert/log handles and hiding a failed cleanup (a leaked dir or a
lingering fixed-port child). Now give a brief extra grace if the process is still
running after Kill()+WaitForExit, then attempt Remove-Item with -ErrorAction Stop
and report a failure via a WARN rather than swallowing it silently.

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

* fix(parser): do not require the unused classic reading to parse (write line/chunk)

Maintainer review: the ambiguous merged form parses BOTH the stream reading and
the classic file-write reading (via cursor rewind), but the classic reading is
only USED at runtime when the target is a file. Requiring it to parse could
reject a valid stream-only value whose grammar the classic reading can't consume.
Make the fallback parse fallible (.ok()) and always resume right after the stream
value, so the statement parses on its stream reading alone when the classic
reading doesn't; the fallback is simply omitted (runtime then has no file
reading to fall back to, which is correct — that reading was not valid anyway).
Existing tests still exercise the fallback-parses path.

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

* fix(parser): consume a connective before 'as' in start streaming response

Copilot review: the streaming-response clause loop broke on an unrecognized token
after and/with WITHOUT consuming the connective, so `... with status 200 and as
out` failed at expect_token(as) reporting the leftover `and` instead of parsing.
Consume a connective that directly precedes `as` (the end-of-clauses join) so the
`as <name>` binding parses cleanly, and correct the comment to describe the
actual behavior for a connective before any other unrecognized token.

Test: `start streaming response to req with status 200 and as out` parses.

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

* fix(P1): bound an active outbound read by the absolute stream deadline

run_http_with_budget derived its timeout purely from the run/budget duration and
DISCARDED the caller's configured_timeout, which already encodes the stream's
idle timeout AND its remaining absolute-total deadline (min(idle, remaining) from
stream_pull). So with timeout_seconds=10 and outbound_stream_max_seconds=1, a
head-then-stall upstream let `wait for next chunk` wait ~10s instead of ~1s.

Compose the operation deadline as MIN(configured_timeout, run/budget duration),
reporting the stream Timeout or the budget Deadline depending on which bound
fired; because configured_timeout is always finite, the read is now bounded even
when the budget has no run-wide deadline. Also start the absolute total-lifetime
clock at request initiation (before send()), not after the head arrives, so
connect/header time counts toward the documented total, and bound the head phase
by the remaining-to-total as well.

Red->Green: new real-socket test outbound_stream_deadline_test — a mock upstream
sends the head then stalls; the read now fails at ~1s (was ~10s). Existing
outbound HTTP budget/cancellation/stream tests still pass.

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

* test: build WflConfig with struct-update syntax (clippy field_reassign_with_default)

CI's clippy --all-targets flagged the P1-B deadline test's
`let mut config = WflConfig::default(); config.x = ...` (field_reassign_with_default,
denied by -D warnings). Use struct-update syntax instead. No behavior change.

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

* fix(P1): make outbound streams handler-owned, closed on every exit path

Maintainer review: outbound httpstream* handles lived only in the interpreter-
wide IoClient.stream_handles map; RunState/IsolatedHandler::drop tracked and
closed downstream response streams and pending requests but NOT upstream handles.
A handler that opened `... stream response as up` and then ended (normal, error,
panic, cancellation, loop exit) without closing leaked the upstream connection
until the whole interpreter tore down.

Track outbound handle ids per-handler in RunState.open_http_streams (swapped
per-poll like the other run state). Add on open; untrack on EOF/error/explicit
close; and on every handler exit — IsolatedHandler::drop for the concurrent loop,
and close_open_http_streams() at the serial-loop/program-exit cleanup sites —
drop any still-open handles from stream_handles (via a synchronous try_lock),
which cancels their in-flight upstream requests. Idempotent and best-effort.

Red->Green real-socket test (outbound_stream_ownership_test): a program opens an
outbound stream, reads one chunk, and ends WITHOUT close while the interpreter is
still alive; the mock upstream observes its client disconnect only because
handler-exit cleanup cancelled the upstream (confirmed Red by disabling the
cleanup). Existing concurrent/server-streaming isolation tests still pass.

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

* fix(P1): cancel a blocked upstream read when the downstream client disconnects

Maintainer review: a proxy handler blocked in `wait for next line/chunk` on the
upstream received no signal when the browser disconnected — it was only noticed
at the next downstream write, or (now) at the absolute stream deadline. Give the
read a proactive disconnect signal: the downstream response stream's mpsc Sender
`closed()` resolves when hyper drops the client's body Receiver. Clone the
senders of the handler's open response streams (no RefCell borrow held across the
await) and `select!` the upstream read against "any downstream disconnected".
On disconnect, dropping the read future cancels the upstream; we also close the
handle and return a catchable Cancelled error so the handler unwinds and its
handler-owned cleanup runs.

Red->Green real-socket test (outbound_stream_disconnect_test): mock upstream
sends one chunk then stalls; a WFL concurrent proxy relays it; the client reads
the first chunk and disconnects while the handler is blocked on the stalled
upstream read; the mock observes its own connection close within the window only
because the blocked read was cancelled (confirmed Red by disabling the select).
Full lib + HTTP/concurrent/streaming suites and clippy --all-targets stay green.

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

* docs: outbound-stream deadline/ownership/disconnect now accurate (P1 shipped)

Update the streaming docs and add a Dev Diary entry for the two P1 lifecycle
fixes: reads are bounded by min(idle, remaining absolute deadline) started at
stream open; a stream is released on handler/program exit on any path (outbound
handles are handler-owned); and a downstream disconnect cancels a blocked
upstream read promptly rather than waiting out the deadline.

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

* test(P1): failing burst-disconnect regression for the concurrent loop

A burst of >256 downstream disconnects currently trips the concurrent
main loop's global consecutive-failure breaker (each disconnect is
miscounted as a handler failure), tearing the loop down so an unrelated
/ping is refused. Red: /ping is refused after 256 disconnects because
the loop broke. This is the R3 negative/availability test that the fix
must turn green.

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

* fix(P1): treat a client disconnect as cancellation, not a handler failure

A downstream disconnect that cancels a proxy handler's blocked upstream
read was surfaced as a generic budget error and fed into the concurrent
main loop's single global consecutive-failure breaker: every disconnect
incremented the counter and backed off, and 256 disconnects with no
interleaved success broke the whole loop, turning an ordinary client
hang-up into a denial of service.

The disconnect branch now returns a distinct HttpClientError::Disconnected
mapped to a new ErrorKind::Cancelled (catchable like any error). The
concurrent loop recognizes a Cancelled handler outcome as a normal,
expected cancellation: it releases the handler (its owned upstream/response
streams are already closed on unwind) without touching the failure counter
or backing off. Internal budget-cancellation of an outbound request keeps
its ResourceLimit kind, so only a real downstream disconnect is exempted.

Turns tests/concurrent_disconnect_burst_test.rs green: a burst of 270
disconnects no longer tears the loop down; an unrelated /ping is still
served (was refused; ~13s -> ~0.9s).

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

* style: cargo fmt (expand HttpClientError::Timeout, wrap assert)

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

* test(P1): failing coverage for property-then-index parsing

A bracket index right after a .property/.method access split into a
separate list-literal statement, so `store ct as obj.headers["k"]`
silently dropped the key and bound `ct` to the whole headers map.

Red evidence:
- property_index_access_test: AST asserts one IndexAccess over the
  PropertyAccess (currently splits); a runtime map lookup yields the
  indexed value "BBB" (currently returns the whole map -> INDEX_WRONG).
- stream_handle_type_test: the previously typecheck-only dot test now
  also asserts the parse structure (it was a false green: both halves of
  the split still type-check).

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

* fix(P1): compose a bracket index after a .property/.method access

A bracket index immediately after an identifier property access
(`obj.headers["content-type"]`) or method call (`obj.get()[0]`) was
dropped: the primary-expression dispatch returned the PropertyAccess/
MethodCall early, before the postfix loop could consume the `[...]`, so
the bracket was re-parsed as a standalone list-literal statement and the
lookup silently vanished (`ct` bound to the whole map).

Route both the property-access and method-call fast paths through a new
parse_trailing_bracket_index helper that folds any chained `[...]` index
accesses onto the base (`grid.rows[0][1]`). The shared postfix loop and
the static-member `.` arm are untouched, so a trailing `.member` keeps
its pre-existing behavior.

Turns tests/property_index_access_test.rs and the strengthened dot test
in tests/stream_handle_type_test.rs green.

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

* test(P1): failing coverage for span-mismatched write fallback

`write line min with a: 1 and b: 2 to <target>`: the stream reading
consumes the builtin call `min` with named args, but the classic
file-write reading of `line min` can only parse `line min with a` (a
shorter span, stopping at the `:`). Retaining that partial parse as the
fallback corrupts a file write.

Red evidence:
- AST: the span-mismatched fallback must be dropped (currently retained
  as a partial Concatenation).
- runtime: with `line min`/`a` defined, the old fallback writes
  "CORRUPT..." to the file; the fix must make it a clean error instead.

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

* test(P1): failing coverage for absolute stream lifetime on buffered reads

outbound_stream_max_seconds must be a true absolute lifetime, but
next_line/next_chunk serve locally-buffered bytes before consulting the
deadline. Red: a buffered `wait for next line` taken ~1.5s after opening
(past the 1s absolute lifetime) is still served instead of failing.

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

* fix(P1): drop a span-mismatched classic write fallback

The ambiguous `write line|chunk <ident> ... to <target>` form parses two
independent readings of the same continuation: the stream value and the
classic file-write fallback. The fallback was kept whenever it merely
parsed, even if it consumed a DIFFERENT span than the stream reading —
so `write line min with a: 1 and b: 2 to <file>` retained a partial
`line min with a` fallback (the classic reading stops at the `:` that the
builtin-call stream reading consumes as a named arg), corrupting the file
write. Keep the fallback only when it consumed exactly to the stream
reading's end checkpoint; otherwise the two readings disagree and there is
no valid classic interpretation, so the non-stream target is a clean error.

Turns tests/write_line_backcompat_test.rs (two new cases) green while the
matching-span back-compat cases still pass.

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

* fix(P1): enforce the absolute stream lifetime on buffered reads

next_line/next_chunk served locally-buffered bytes before consulting the
handle's absolute deadline, so outbound_stream_max_seconds bounded only
network reads: a proxy that pulled a multi-line chunk could keep draining
the buffer past the stream's absolute lifetime. Add check_stream_deadline
and call it before serving buffered bytes in next_chunk and before serving
a buffered line in next_line's loop; on expiry the handle is dropped
(cancelling the upstream). An empty-buffer read already expired via
stream_pull's identical check.

Turns tests/outbound_stream_absolute_lifetime_test.rs green.

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

* test(P1): failing coverage for outbound cleanup on a dropped interpret()

A dropped/cancelled interpret() future does not run the handler-exit or
program cleanup sites, so an outbound stream handle parked (opened, not
mid-read) in IoClient.stream_handles leaks the upstream until the
interpreter itself is dropped. Red: with the interpreter kept alive after
the future is dropped, the mock upstream is never disconnected.

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

* fix(P1): close outbound handles when the interpret() future is dropped

Handler-exit and program-cleanup sites close outbound stream handles on
normal control-flow exits, but a dropped/cancelled interpret() future runs
none of them, leaking a parked (opened, not mid-read) upstream until the
interpreter itself is dropped. Add an RAII OutboundStreamCleanup guard held
for the whole interpret_inner body: it shares open_http_streams and the
IoClient via Rc, so its Drop closes the tracked handles (cancelling their
upstreams) even as the future unwinds and the interpreter stays alive. On a
normal run the exit sites drain t…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant