Skip to content

Fix concurrent handler isolation for #642 (capture, modules, streams, depth) - #643

Merged
logbie merged 23 commits into
mainfrom
claude/github-issue-642-4q0tfb
Jul 25, 2026
Merged

Fix concurrent handler isolation for #642 (capture, modules, streams, depth)#643
logbie merged 23 commits into
mainfrom
claude/github-issue-642-4q0tfb

Conversation

@logbie

@logbie logbie commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Completes the remaining work from issue #642 (re-review of the merged #641 streaming/concurrency head): four release-blockers for concurrent web serving, the follow-up correctness/compatibility items, and the PR-review feedback rounds. Every behavioral change landed as a Red test-only commit that is an ancestor of its Green fix (testing.md §6.2 path 1); pairs are listed under Test evidence below.

Key Changes

Runtime isolation (concurrent handler safety)

  1. Handler-local output capture (src/interpreter/io_capture.rs, src/interpreter/mod.rs) — the execute file ... and read output capture stack joins the per-handler RunState swap; handlers inherit a clone of the ambient stack so output still reaches an enclosing capture; CaptureGuard removes its buffer by Rc identity instead of a blind pop. Regression: tests/concurrent_execute_capture_test.rs.
  2. Handler-local module loading (src/interpreter/mod.rs) — current_source_file and loading_stack join the RunState swap, so relative include/load module paths resolve against the handler's own context and a sibling's in-flight load is never misreported as a cycle; ModuleLoadGuard restores/removes by identity. Regression: tests/concurrent_module_loading_test.rs.
  3. Response-stream ownership (src/interpreter/mod.rs) — write/flush/close on a live stream require it to be in the current handler's open list, so a handler holding a sibling's handle (e.g. via a shared global) cannot inject into, flush, or truncate the sibling's response. A closed stream keeps the closed-stream error for write/flush; close on a stale handle stays an idempotent no-op (nothing live left to protect). Regression: tests/concurrent_stream_ownership_test.rs.
  4. Timeout evaluation ordering (src/interpreter/mod.rs) — wait for request ... with timeout <expr> evaluates the expression before locking the shared receiver, so one handler's awaiting expression no longer stalls every sibling's dequeue. Each concurrent slot evaluates its own operand at iteration start (documented). Regression: tests/concurrent_timeout_eval_lock_test.rs.
  5. Live recursion depth (src/interpreter/mod.rs) — handlers seed call_depth from the live depth at loop entry (mirroring execute file child seeding), so enclosing action frames stay counted; an early test variant reproduced a real native stack overflow. IsolatedHandler::drop now drops the handler future with the handler's own run state installed, so RAII guards in a future dropped mid-suspend unwind against the right context. Regression: tests/concurrent_recursion_depth_test.rs.

Parser/typechecker/analyzer compatibility

  1. Classic write line|chunk continuations (src/parser/stmt/io.rs) — the bare-marker guard now includes starts/ends/: (the arms it was missing relative to parse_binary_continuation_inner), so write line starts with "/" to f and write line : to f keep their pre-streaming file-write meaning. Regression: tests/write_web_postfix_test.rs.
  2. Exact flush (…) / flush call … (src/interpreter/mod.rs, src/analyzer/mod.rs, src/typechecker/mod.rs) — these were two legacy statements (bare flush + the operand); the fallback now evaluates both in order when the bare flush binding exists, and all three passes validate the operand. Regression: tests/flush_action_backcompat_test.rs.
  3. repeat until runtime-order typechecking (src/typechecker/mod.rs) — body checked first under a backedge fixed point, then the condition against post-body state; bodies containing an early exit (break/exit/return) soften the condition to the header⊔post-body join, since runtime skips the condition on those paths and type errors are fatal in load module. Regressions: tests/typechecker_repeat_until_backedge_test.rs.
  4. WebSocket handler binding (src/typechecker/mod.rs) — the binding symbol is recreated (Type::Unknown) in the handler's checker scope, shadowing an outer same-named symbol the way runtime define_direct does. Regression: tests/typechecker_websocket_binding_scope_test.rs.

Docs & process

  • Docs/04-advanced-features/web-servers.md: what concurrently isolates per handler; response-stream ownership semantics (including exact close-after-close behavior); per-slot timeout-expression evaluation guidance.
  • Dev diary/2026-07-25-issue-642-remaining-blockers.md: full Red→Green map and rationale.
  • Deferred (maintainer ruling requested): try/finally error-alias scope. Runtime keeps the alias live through finally; analyzer/typechecker deliberately resolve the outer binding there, and each direction is locked in by existing tests — aligning either way is a breaking decision (details in the Dev Diary).

Test evidence

  • Risk class: R3 (concurrency, lifecycle, streaming, backward compatibility).
  • Red evidence (§6.2 path 1) — test-only Red commit → Green fix, all on this branch (base 62a1b30):
    • timeout/lock ordering 01ee8634556a65
    • execute-file capture 91ffee1952e270
    • module loading 822778dcfd3440
    • stream ownership 3ba71e71f71935
    • recursion depth bd183ad0320d24
    • write continuations dff4f3bc4f74bc
    • flush legacy forms cd7bef4daca8c7
    • repeat-until + ws binding f2e28d959a71cd
    • repeat-until break-path (review round) c6e719c7553a6a
    • (History was rewritten once pre-push for committer identity only; commit messages cite pre-rewrite red hashes — the Dev Diary maps them.)
  • Layers run locally (Linux x86-64): cargo fmt --all -- --check; cargo clippy --all-targets --all-features -- -D warnings; cargo test --workspace (all green); ./scripts/run_integration_tests.sh — TestPrograms walk 111 passed / 0 failed / 24 documented skips (backward-compat gate §11.6); ./scripts/run_web_tests.sh — 3/3; python scripts/validate_docs_examples.py --force — 18/18.
  • Platforms: Linux locally; Windows via this PR's CI matrix.
  • Not applicable: transpiler changes (streaming statements already rejected with a clear error); benches.
  • Residual risk: per-slot timeout-expression evaluation is burstier on idle servers than the old lock-serialized accident (documented; eval-once caching offered as follow-up if preferred). Test-harness duplication across the five new concurrency tests follows the existing per-file convention (a shared tests/common helper would emit dead-code warnings in the other suites that include common).
  • Rollback: revert this branch's commits; no schema/config/persisted-state changes.

https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K


&lt;img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review"&gt;

Summary by CodeRabbit

  • New Features
    • Improved concurrent web-server handling with isolated request contexts, non-blocking timeout evaluation, and safer module loading.
    • Added ownership protection for streaming responses; unauthorized handlers can no longer write, flush, or close another handler’s stream.
  • Bug Fixes
    • Preserved legacy flush behavior and improved classic write syntax compatibility.
    • Corrected loop type checking, WebSocket handler scoping, recursion limits, and concurrent output capture.
  • Documentation
    • Expanded guidance on concurrent request handling and streaming response ownership.

claude added 17 commits July 25, 2026 13:17
…andlers (#642)

Red evidence for issue #642 blocker: `wait for request ... with timeout`
evaluates the timeout expression while holding the shared request-receiver
mutex, so one handler's awaiting timeout expression parks every concurrent
sibling. Fails now with: request stalled ~1.8s behind the sibling's
timeout-expression evaluation (assert < 1s).

Base: 62a1b30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…shared receiver (#642)

The timeout clause of `wait for request` is an arbitrary WFL expression;
evaluating it under the receiver mutex shared by all concurrent handlers
let one handler's awaiting timeout expression (user action that sleeps,
does I/O, ...) stall every sibling's dequeue. Evaluate it first, then lock.

Green for tests/concurrent_timeout_eval_lock_test.rs (red at 3cce244).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
#642)

Red evidence for issue #642 blocker: the io_capture stack is a single
thread-local shared by every concurrent handler, so a handler that awaits
mid-capture leaks sibling output into its buffer and loses its own lines
to a sibling's buffer. Fails now with:
  - /a captured only "ALPHA-1" (its second line landed in /b's buffer)
  - /cap captured "CHILD-1 NOISE-FROM-SIBLING CHILD-2"

Base: 62a1b30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…handlers (#642)

The io_capture stack is thread-local, so interleaved concurrent handlers
shared one capture stack: sibling output landed in whichever buffer was
innermost, and LIFO guard pops mismatched when handlers finished out of
push order. Now:

- the capture stack is part of the per-handler RunState swap: each
  handler's own stack is installed for the duration of each poll, with
  the ambient stack parked and restored around it;
- a fresh handler inherits a clone of the ambient stack, so uncaptured
  handler output still reaches an enclosing execute-file capture;
- CaptureGuard removes its buffer by Rc identity instead of popping
  blindly, so out-of-order completion and guards dropped while their
  handler's stack is parked cannot remove another capture's buffer.

Green for tests/concurrent_execute_capture_test.rs (red at 3efd592).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
… path base (#642)

Red evidence for issue #642 blocker: current_source_file and loading_stack
are interpreter-global, so a handler parked mid-include makes a sibling's
include of the same module fail with a false 'Circular dependency', and
makes a sibling's relative include resolve against the parked handler's
module directory. Both /b requests currently get 500.

Base: 62a1b30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…nt handlers (#642)

current_source_file and loading_stack move into the per-handler RunState
swap, so each concurrent handler resolves relative module paths against
its own context and cycle/import-depth checks see only its own in-flight
loads (plus inherited enclosing loads, which remain true cycles).
ModuleLoadGuard now restores the source file only when its module is
still the installed context and removes its stack entry by value, so a
guard dropped while its handler's context is parked cannot clobber the
ambient context.

Green for tests/concurrent_module_loading_test.rs (red at bc8657f).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…esponse stream (#642)

Red evidence for issue #642 blocker: stream verbs act on the global
stream map with no ownership check, so a handler holding another
handler's stream handle (via a shared global) can inject bytes into the
sibling's body, flush it, or close it mid-response. Fails now with
'write:allowed flush:allowed close:allowed'.

Base: 62a1b30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…se (#642)

Stream verbs previously acted on the global stream map with no ownership
check, so a handler holding a sibling's stream handle (via a shared
global) could inject bytes into the sibling's response, flush it, or
close it mid-response — and because StreamWriteStatement clones the
sender out of the map before awaiting, a sibling's close did not even
stop an in-flight write. write/flush/close now require the handle to be
in the current handler's open-stream list; a live stream owned by
another handler reports ownership, a missing one keeps the closed-stream
error, and re-closing one's own already-closed stream stays a no-op.
With ownership enforced, write and close on the same stream can no
longer race across handlers (a handler is sequential within itself).

Green for tests/concurrent_stream_ownership_test.rs (red at c262e44).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…t live depth (#642)

Red evidence for issue #642 follow-up: a handler under a concurrent loop
nested in user actions seeds call_depth from base_call_depth (run entry)
instead of the live depth at loop entry, so enclosing frames go
uncounted and the depth ceiling no longer bounds the native stack (an
early variant of this test aborted with a real stack overflow on a 2MB
thread). Fails now with 'not-limited' vs expected 'depth-limited'.

Base: 62a1b30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
… futures under their own state (#642)

Handlers under a nested `main loop concurrently:` seeded call_depth from
base_call_depth (run entry, usually 0), discarding the live enclosing
action frames — so the depth ceiling stopped bounding the native stack
(reproducibly overflowing a 2MB thread in debug builds). Handlers now
seed from the live depth at loop entry, mirroring execute-file's child
seeding.

IsolatedHandler::drop now takes the handler future and drops it with the
handler's own run state swapped in: RAII guards inside a future dropped
mid-suspend (call-depth, capture, module-load) previously unwound
against whatever ambient state was installed at teardown, corrupting the
enclosing context's accounting.

Green for tests/concurrent_recursion_depth_test.rs (red at f604d46).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…tinuations (#642)

Red evidence for issue #642 follow-up: the bare-marker guard in
parse_write_to_statement enumerates continuation tokens by hand and is
missing KeywordStarts, KeywordEnds, and Colon relative to the binary
continuation parser, so 'write line starts with ...' silently mis-parses
into the streaming branch and 'write line : to ...' parse-fails, while
the identical programs with any other variable name still work. 4 new
tests fail now.

Base: 62a1b30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…write line|chunk (#642)

The bare-marker guard now includes KeywordStarts, KeywordEnds, and Colon,
matching the operator arms of parse_binary_continuation_inner, so classic
programs like 'write line starts with "/" to f' and 'write line : to f'
keep their pre-streaming file-write meaning instead of mis-parsing into
the streaming branch. Comment now ties the set to the binary parser as
the authoritative list.

Green for the 4 tests added in 294aaac.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…acy side effects (#642)

Red evidence for issue #642 follow-up: pre-streaming these forms were TWO
statements (bare 'flush' expression statement + the operand expression
statement). The current fallback evaluates only Variable("flush") and
discards the operand, so its side effects (a call) no longer fire. 3 new
tests fail now with only FLUSH_RAN in the output.

Base: 62a1b30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…cy statement (#642)

Pre-streaming, these exact forms were two statements: the bare 'flush'
expression statement followed by the operand expression statement. The
fallback now executes both in the original order when the bare 'flush'
binding exists (exact form only — merged forms' fallback already spans
the operand), and the analyzer/typechecker validate the operand the same
way, keeping all three passes aligned with the runtime.

Green for the 3 tests added in 6fe8e86.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
… shadowed in checker scope (#642)

Red evidence for issue #642 follow-ups:
- RepeatUntilLoop's typechecker arm infers the condition BEFORE the body
  (runtime runs body first, same scope, no fixed point), so a body
  statement retyping a binding (e.g. start streaming response ... as out)
  is invisible to the condition — a guaranteed runtime type error passes
  the checker.
- WebSocketHandlerStatement's typechecker arm pushes a scope but never
  defines the binding symbol, so the handler body is checked against an
  outer same-named symbol's concrete type — flagging runtime-valid
  programs (Cannot index into Number).

Base: 62a1b30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…g in checker scope (#642)

- RepeatUntilLoop now checks the body first under a backedge fixed point
  and then the condition against the POST-body type state (a new
  check_loop_body_fixed_point_post_body keeps the body's final state
  installed — repeat-until always runs the body before the condition, so
  the header-state restore used by while would hide body retypings).
- WebSocketHandlerStatement recreates its binding symbol (Type::Unknown)
  inside the pushed checker scope, shadowing an outer same-named symbol
  the way runtime define_direct does, so handler bodies are no longer
  checked against the outer symbol's concrete type.

Green for the 3 tests added in c16d7ba.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…rship (#642)

- web-servers.md: the concurrently isolation bullet now enumerates what
  is handler-local (count-loop state, live recursion depth, execute-file
  capture, module loading) and notes timeout expressions are evaluated
  before contending for the next request; new Ownership note for
  streaming responses.
- Dev Diary entry mapping each #642 item to its Red/Green commits and
  recording the deferred try/finally alias-scope decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
Copilot AI review requested due to automatic review settings July 25, 2026 13:27
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 084b0bc3-e4e1-4c39-ac6c-741eda531d40

📥 Commits

Reviewing files that changed from the base of the PR and between 0163817 and e0985c5.

📒 Files selected for processing (4)
  • Docs/04-advanced-features/web-servers.md
  • src/interpreter/mod.rs
  • src/typechecker/mod.rs
  • tests/typechecker_repeat_until_backedge_test.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • Docs/04-advanced-features/web-servers.md
  • src/interpreter/mod.rs
  • src/typechecker/mod.rs

📝 Walkthrough

Walkthrough

The PR isolates concurrent handler execution state, enforces response-stream ownership, moves timeout evaluation outside receiver locking, preserves legacy IO behavior, aligns typechecking with runtime semantics, and adds documentation and regression tests.

Changes

Lifecycle and concurrency

Layer / File(s) Summary
Handler-local execution context
src/interpreter/io_capture.rs, src/interpreter/mod.rs, tests/concurrent_execute_capture_test.rs, tests/concurrent_module_loading_test.rs, tests/concurrent_recursion_depth_test.rs
Capture stacks, module-loading context, source paths, and recursion depth are isolated per handler; suspended futures restore their handler state during cleanup.
Request timing and response ownership
src/interpreter/mod.rs, Docs/04-advanced-features/web-servers.md, tests/concurrent_timeout_eval_lock_test.rs, tests/concurrent_stream_ownership_test.rs
Timeout expressions are evaluated before receiver locking, while stream operations reject non-owner handlers.
Legacy IO compatibility
src/analyzer/mod.rs, src/parser/stmt/io.rs, src/interpreter/mod.rs, tests/flush_action_backcompat_test.rs, tests/write_web_postfix_test.rs
Legacy flush operand evaluation and classic write continuations preserve side effects and parsing behavior.
Runtime-aligned typechecking
src/typechecker/mod.rs, tests/typechecker_repeat_until_backedge_test.rs, tests/typechecker_websocket_binding_scope_test.rs
repeat until conditions observe stabilized post-body types, and WebSocket bindings shadow outer symbols in handler scope.
Issue 642 change record
Dev diary/...
The development diary records the concurrent-server blockers, compatibility fixes, deferred scope decision, and unchanged behaviors.

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

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RequestHandler
  participant RunState
  participant ResponseStream
  Client->>RequestHandler: send concurrent request
  RequestHandler->>RunState: install handler-local context
  RequestHandler->>ResponseStream: write, flush, or close
  ResponseStream-->>RequestHandler: allow owner or return ownership error
  RequestHandler-->>Client: return response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: concurrent handler isolation fixes for capture, modules, streams, and recursion depth.
Docstring Coverage ✅ Passed Docstring coverage is 86.67% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/github-issue-642-4q0tfb

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

❤️ Share

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses remaining correctness and backward-compatibility issues related to issue #642, focusing on isolating concurrent handler execution under main loop concurrently: and restoring pre-streaming parsing/typechecking behaviors where concurrency/streaming changes regressed legacy programs.

Changes:

  • Hardened concurrent-handler isolation in the interpreter (handler-local capture/module-load context, explicit handler-future drop ordering, response-stream ownership checks, timeout evaluation ordering, recursion depth seeding).
  • Fixed parser/typechecker/analyzer alignment for legacy-compatible forms (write line|chunk …, exact flush (…) / flush call …, repeat until runtime order, WebSocket handler binding shadowing).
  • Added targeted Red→Green regressions plus updated docs and a dev diary entry documenting the fixes.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/write_web_postfix_test.rs Adds regression coverage for classic `write line
tests/typechecker_websocket_binding_scope_test.rs Regression to ensure WebSocket handler binding shadows outer symbols in typechecking.
tests/typechecker_repeat_until_backedge_test.rs Regression for repeat until body-first typechecking with correct backedge behavior.
tests/flush_action_backcompat_test.rs Regression ensuring exact legacy flush (…) / flush call … still evaluates operand side effects.
tests/concurrent_timeout_eval_lock_test.rs Regression ensuring timeout expressions don’t stall sibling handlers by holding the receiver lock.
tests/concurrent_stream_ownership_test.rs Regression ensuring response streams are handler-owned (no sibling write/flush/close).
tests/concurrent_recursion_depth_test.rs Regression ensuring handler recursion budget includes enclosing action frames.
tests/concurrent_module_loading_test.rs Regression ensuring handler-local module loading context (no false cycles / wrong relative base).
tests/concurrent_execute_capture_test.rs Regression ensuring output capture is handler-local (no cross-wired capture buffers).
src/typechecker/mod.rs Implements repeat until body-first fixed-point checking; restores legacy flush operand checks; fixes WS handler binding recreation.
src/parser/stmt/io.rs Extends classic-continuation guard tokens for `write line
src/interpreter/mod.rs Adds per-handler run-state swapping for capture/module-load context, explicit handler drop ordering, response-stream ownership enforcement, timeout eval ordering, live-depth seeding.
src/interpreter/io_capture.rs Makes capture guards identity-based; adds stack swap/snapshot for per-handler capture isolation.
src/analyzer/mod.rs Analyzes legacy flush operand expression for exact forms to preserve name validation.
Docs/04-advanced-features/web-servers.md Documents concurrency isolation details and response-stream ownership semantics.
Dev diary/2026-07-25-issue-642-remaining-blockers.md Records Red→Green evidence and rationale for the #642 follow-up fixes.

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

Comment thread src/interpreter/mod.rs Outdated
Comment on lines +4684 to +4688
/// interpreter's own list under serial execution). Stream verbs
/// (`write`/`flush`/`close`) require ownership, so a handler holding a
/// sibling's stream handle (e.g. through a shared global) cannot inject
/// into, flush, or truncate the sibling's response (#642).
fn owns_response_stream(&self, handle_id: &str) -> bool {
Comment thread src/interpreter/mod.rs Outdated
Comment on lines +7103 to +7107
// Re-closing an already-closed own stream stays a
// silent no-op (the live-elsewhere case errors).
if !self.owns_response_stream(&id)
&& self.server_response_streams.borrow().contains_key(&id)
{

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed by documenting the actual guarantee rather than adding post-close ownership tracking (627f679 for the user docs, 7553a6a for the owns_response_stream/close-site comments — this thread and the two sibling comments were all the same nuance).

Rationale for not tracking previously-owned/closed ids: the enforced invariant is that a live response can't be injected into, flushed, or truncated by a non-owner — once the stream is closed there is no response left to protect, so a stale-handle close is a harmless idempotent no-op for any holder. Per-handler closed-id tracking would add unbounded per-handler state and would break the serial loop's existing idempotent re-close across iterations (the per-iteration drain clears the open list, so iteration N+1's re-close of an iteration-N stream would start erroring). write/flush on a closed stream still fail with the closed-stream error.


Generated by Claude Code

Comment on lines +547 to +552
**Ownership:** a response stream belongs to the handler that started it. Under
`main loop concurrently:`, `write`, `flush`, and `close` on a stream handle that
another handler owns (for example one shared through a global variable) fail
with a catchable error instead of injecting into — or truncating — the owner's
response. Re-`close`-ing a stream you own that is already closed stays a silent
no-op.
#642)

Copilot review on PR #643: ownership is enforced for LIVE streams; once a
stream is closed there is no response left to protect — write/flush keep
the closed-stream error and close is an idempotent no-op for any handler
holding the stale handle. Say exactly that instead of overstating the
close guarantee.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
Copilot AI review requested due to automatic review settings July 25, 2026 13:33

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0163817603

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread src/interpreter/mod.rs
Comment on lines +10076 to +10079
let timeout_duration = if let Some(timeout_expr) = timeout {
let timeout_val = self
.evaluate_expression(timeout_expr, Rc::clone(&env))
.await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid eagerly evaluating every handler timeout

When main loop concurrently begins with wait ... with timeout call f, all 256 prefilled handler futures can reach this evaluation before any request is dequeued, so an idle server now invokes f once per handler slot. This bypasses the first-wait invariant documented in src/analyzer/mod.rs:1241-1260 to prevent speculative pre-request side effects; for example, a timeout action that increments state or performs external I/O fires roughly 256 times instead of once before the first request. Avoid holding the receiver lock across a slow evaluation without allowing every queued handler to evaluate the timeout eagerly.

AGENTS.md reference: AGENTS.md:L145-L148

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Acknowledged, but keeping the current behavior deliberately — the two goals are in genuine tension. Any scheme that serializes timeout-expression evaluation (the old accidental behavior, or a separate eval mutex) recreates the P1 this fixes: one handler's awaiting expression stops every sibling from dequeuing, which was a merge-blocker in #642. The eval must therefore happen outside any shared lock, and each of the N handler iterations evaluates its own operand when it starts — the burst is inherent to N concurrent iterations, not to the lock reorder (each iteration always evaluated it eventually; only the timing/parallelism changed).

The first-wait analyzer invariant still holds for its stated purpose (no user statements run per-slot before a request); the timeout operand is part of the wait itself. Documented in 7553a6a: web-servers.md now states each slot evaluates its own timeout expression at iteration start and that timeout expressions should stay side-effect free. If eval-once-per-loop-entry caching semantics are preferred for the operand, that's a language-semantics decision for the maintainer — happy to implement it as a follow-up if wanted.


Generated by Claude Code

Comment thread src/typechecker/mod.rs Outdated
// same backedge fixed point as `while`/`repeat while`, but
// keeping the post-body state) so body retypings are visible
// to the condition (#642).
self.check_loop_body_fixed_point_post_body(body);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude break-only states from repeat conditions

When a repeat until body retypes a binding and then executes break, the runtime exits at the break without evaluating the condition, but this unconditional post-body state makes the checker validate the condition against that retyped binding. For example, a body that starts a response stream as out and then breaks is runtime-valid even if the condition compares the original numeric out, yet it now produces a type error; such errors are fatal while loading modules. Track only condition-reaching body paths for condition inference while retaining break-path state for code after the loop.

AGENTS.md reference: AGENTS.md:L19-L22

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed — load module does treat type errors as fatal, so this was a real compat hazard, not just imprecision. As of 7553a6a (red test in c6e719c): when the body subtree contains an early exit (break/exit/return, including inside if/try/nested blocks), the condition — and post-loop state — is checked against the join of the stabilized header and the post-body state, so a retype-then-break body typechecks cleanly again. Straight-line bodies keep the precise post-body check (the original #642 soundness fix). The scan is deliberately conservative: a nested loop's break also softens, which can only under-report, never falsely reject.


Generated by Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/concurrent_module_loading_test.rs (1)

22-65: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the duplicated server harness into tests/common. All five new concurrency tests copy the same start_server_thread / wait_for_server / shutdown trio, and each already declares mod common for free_tcp_port — so the shared module is the obvious home. Parameterizing stack size and optional source file covers every current variant.

  • tests/concurrent_module_loading_test.rs#L22-L65: move start_server_thread (with its Option<PathBuf> source-file parameter), wait_for_server, shutdown, and wfl_path into tests/common.
  • tests/concurrent_recursion_depth_test.rs#L26-L73: drop the local copies and call the shared helper, passing the 16 MiB stack size (keep the stack-size rationale comment at the call site).
  • tests/concurrent_execute_capture_test.rs#L26-L62: drop the local copies in favor of the shared helpers; keep write_child local.
  • tests/concurrent_stream_ownership_test.rs#L23-L59: drop the local copies in favor of the shared helpers.
  • tests/concurrent_timeout_eval_lock_test.rs#L26-L68: drop the local copies in favor of the shared helpers; the doc comments on readiness/shutdown can move with them.

As per coding guidelines, "Place Rust unit and integration tests under tests/, using feature-oriented names such as *_test.rs" — a shared common module keeps the feature-named test files focused on their scenario.

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

In `@tests/concurrent_module_loading_test.rs` around lines 22 - 65, Move the
duplicated start_server_thread, wait_for_server, shutdown, and wfl_path helpers
into tests/common, preserving optional source-file handling and parameterizing
stack size as needed. In tests/concurrent_module_loading_test.rs:22-65, relocate
the helpers; in tests/concurrent_recursion_depth_test.rs:26-73,
tests/concurrent_execute_capture_test.rs:26-62,
tests/concurrent_stream_ownership_test.rs:23-59, and
tests/concurrent_timeout_eval_lock_test.rs:26-68, remove local copies and use
the shared helpers, retaining the recursion stack-size rationale, write_child,
and applicable readiness/shutdown documentation at their respective sites.

Source: Coding guidelines

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

Nitpick comments:
In `@tests/concurrent_module_loading_test.rs`:
- Around line 22-65: Move the duplicated start_server_thread, wait_for_server,
shutdown, and wfl_path helpers into tests/common, preserving optional
source-file handling and parameterizing stack size as needed. In
tests/concurrent_module_loading_test.rs:22-65, relocate the helpers; in
tests/concurrent_recursion_depth_test.rs:26-73,
tests/concurrent_execute_capture_test.rs:26-62,
tests/concurrent_stream_ownership_test.rs:23-59, and
tests/concurrent_timeout_eval_lock_test.rs:26-68, remove local copies and use
the shared helpers, retaining the recursion stack-size rationale, write_child,
and applicable readiness/shutdown documentation at their respective sites.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 21209f93-f2fd-4135-8f03-5ad1de32b480

📥 Commits

Reviewing files that changed from the base of the PR and between b6ccb49 and 0163817.

📒 Files selected for processing (16)
  • Dev diary/2026-07-25-issue-642-remaining-blockers.md
  • Docs/04-advanced-features/web-servers.md
  • src/analyzer/mod.rs
  • src/interpreter/io_capture.rs
  • src/interpreter/mod.rs
  • src/parser/stmt/io.rs
  • src/typechecker/mod.rs
  • tests/concurrent_execute_capture_test.rs
  • tests/concurrent_module_loading_test.rs
  • tests/concurrent_recursion_depth_test.rs
  • tests/concurrent_stream_ownership_test.rs
  • tests/concurrent_timeout_eval_lock_test.rs
  • tests/flush_action_backcompat_test.rs
  • tests/typechecker_repeat_until_backedge_test.rs
  • tests/typechecker_websocket_binding_scope_test.rs
  • tests/write_web_postfix_test.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comment on lines +50 to +54
assert!(
typecheck(code).is_ok(),
"a well-typed repeat-until loop must not be rejected: {:?}",
typecheck(code)
);
claude added 2 commits July 25, 2026 13:41
…n types (#642)

Codex review on PR #643: runtime exits at break without evaluating the
condition, and type errors are fatal inside load module, so checking the
condition strictly against post-body state falsely rejects a runtime-
valid retype-then-break body. Also dedupes a double typecheck() call in
the happy-path assertion (Copilot nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…w feedback (#642)

PR #643 review feedback:
- Codex: a body that can break/exit/return skips the repeat-until
  condition at runtime, and type errors are fatal inside load module —
  check the condition (and post-loop state) against the join of header
  and post-body state when the body subtree contains an early exit,
  keeping the precise post-body check for straight-line bodies.
  Green for the red test in c6e719c.
- Copilot: owns_response_stream / close-site comments now state the
  live-stream nuance (close on an already-closed stream is an idempotent
  no-op for any holder) instead of overstating the close guarantee.
- Codex: document that each concurrent handler slot evaluates its own
  timeout expression at iteration start, so timeout expressions should
  stay side-effect free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
Copilot AI review requested due to automatic review settings July 25, 2026 13:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comment thread src/typechecker/mod.rs Outdated
Comment on lines +331 to +336
/// Whether executing `body` can leave its enclosing loop without reaching
/// the loop's own condition: a `break`, `exit`, or `return` anywhere in
/// the statement subtree. Deliberately conservative — a `break` inside a
/// NESTED loop only exits that inner loop, but treating it as early exit
/// merely softens condition checking back to the joined state, it never
/// rejects a valid program.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in eb02330 (red test in 0b3c6c3). The walker now stops descending for break at nested loop boundaries — a nested loop's body is scanned only for exit loop/return, which the runtime re-raises through every enclosing loop's control-flow dispatch (ControlFlow::Exit/Return), while break is absorbed by the nearest loop. Two new regressions pin both directions: a nested-loop break keeps the precise post-body condition check (the retyped binding is reported again), and a nested exit loop still softens.


Generated by Claude Code

claude added 2 commits July 25, 2026 13:50
…condition (#642)

Copilot review on PR #643: a break inside a nested loop only exits that
inner loop, so the outer repeat-until body falls through to its condition
and the precise post-body check must apply — over-broad softening hides
real type errors. exit loop propagates through every enclosing loop and
must keep softening (companion guard test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
…n escape any depth (#642)

Copilot review on PR #643: the early-exit walker now distinguishes break
(absorbed by the nearest loop — nested loops no longer soften the outer
condition) from exit loop/return (re-raised by every loop's control-flow
dispatch, so they count from any nesting depth). Restores the precise
post-body condition check for bodies whose only break is in a nested
loop.

Green for the red test in 0b3c6c3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPs45HiGQ4RQzDdihoti4K
Copilot AI review requested due to automatic review settings July 25, 2026 13:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants