Skip to content

PR #641 follow-up: remaining lifecycle, concurrency, and compatibility blockers #642

Description

@logbie

Scope

This tracks the remaining findings from a re-review of #641 at exact head b25aed57ea50697c596796446d1f47466668773d (69 files, +10,978 / -303).

The exact-head gate is genuinely green: CI run 30098113534 passed Rust tests/formatting/all-features Clippy, the documented integration scripts on Linux and Windows, Windows docs validation (18/18), and Windows web tests (3/3). Most earlier review findings are closed. Green CI does not cover the cases below, though, and I would not merge that exact head until the P1 items are fixed.

1. P1 — request-local failures can still stop the concurrent server

execute_concurrent_main_loop sends every non-Cancelled handler error and every panic through one global 256-consecutive-failure breaker (src/interpreter/mod.rs:4402-4428). That includes request-local upstream/network failures, request-dependent response errors, and an ordinary wait for request ... with timeout expiry (:9040-9058). After enough consecutive instances, the entire loop stops even though the listener and unrelated handlers are healthy.

There is also a concrete disconnect path that still reaches this breaker:

  1. A handler dequeues a request and parks its sender in pending_responses.
  2. A sibling wait for request globally prunes closed senders (:9166-9178).
  3. The owning handler later reaches respond (:9236-9268) or start streaming response (:9489-9518).
  4. The pending entry is missing, so both paths return ErrorKind::General, not Cancelled.
  5. That expected client disconnect is counted as a structural failure.

The new burst test is false-green for this race:

  • it fires only 270 clients with concurrency 40 (tests/concurrent_disconnect_paths_burst_test.rs:24-26), leaving roughly the final batch on the correctly classified direct-send path;
  • it waits only three seconds before /ping (:104-106);
  • the General-failure backoff (src/interpreter/mod.rs:4444-4455) takes about 11.5 seconds to consume 256 failures, so /ping can be handled and reset the counter before the buggy results are drained;
  • its fired counter is incremented but never asserted.

Acceptance criteria:

  • Distinguish structural/pre-request loop failures from expected or request-local outcomes. A handler that accepted a request must not be able to tear down the whole server because that request failed.
  • Treat “pending entry missing while this handler still owns the request” as cancellation/timeout; retain the duplicate-response error when the handler no longer owns it.
  • Repeated finite wait for request timeouts must not terminate an otherwise healthy server.
  • Add real-socket regressions for >256 buffered-response disconnects and disconnect-before-streaming-head-send. The current streaming test reads the head first and exercises only the later write path.
  • Ensure the test proves every intended handler result was consumed before probing /ping (or make the breaker threshold injectable and small in tests).

2. P1 — the outbound hard-lifetime reaper has an ownership race and leaks timer tasks

The new reaper removes a handle once at its deadline (src/interpreter/mod.rs:2018-2035). A body read removes that handle from the shared map for the duration of the await (:2044-2053), then put_stream blindly reinserts it (:2056-2062; called by next_chunk/next_line).

If expiry lands while a read owns the handle, the one-shot removal is a no-op. A ready chunk can win the read/timeout race, and the read can reinsert the expired handle after the reaper has exited. With no subsequent read, the live upstream can outlast the documented real-time hard cap.

Related lifecycle problems:

  • every open stream spawns a timer that remains parked until the cap even after EOF, error, or explicit close, so rapid open/close cycles accumulate roughly request-rate × cap sleeping tasks;
  • when the reaper does remove a parked handle, the next read reports “unknown/already closed” (General) rather than the promised typed timeout;
  • arbitrary u64 config values reach Instant::now() + Duration::from_secs(secs) (:1943-1946), which can panic for extreme values.

Acceptance criteria:

  • Use stable shared per-handle state/cancellation/tombstones, or otherwise make expiry and read ownership one atomic lifecycle.
  • Refuse reinsertion at/after the deadline and preserve the terminal reason as Timeout.
  • Cancel/remove the timer when the stream reaches EOF, errors, or closes; resource usage must remain bounded under rapid open/close.
  • Validate/safely clamp the configured duration so config cannot panic.
  • Add a near-deadline active-read race regression and a rapid open/close resource regression. outbound_stream_open_expiry_test currently covers only an unread handle that stays in the map.

3. P1 — ambiguous write line|chunk checking is still unsound

The aligned analyzer reports the ambiguous lead only when neither candidate exists (src/analyzer/mod.rs:3760-3769). The typechecker then selects a concrete branch, but unresolved variables infer as Unknown without another diagnostic (src/typechecker/mod.rs:3076-3140). That lets the selected runtime branch contain an undefined name.

Concrete text-target regression:

store value as "x"
write line value to "/tmp/out"

The stream lead value exists, so analysis passes. The text target makes runtime select the classic branch and evaluate the missing variable line value. Before this PR, that classic file write was rejected statically. The inverse occurs with a concrete ResponseStream when only line value exists. PropertyAccess is also absent from the aligned analyzer walker, so write line missing.field to "/tmp/out" can evade the “neither lead exists” check.

There are two further branch-selection holes:

  • for a gradual target, src/typechecker/mod.rs:1083-1112 accepts when either speculative branch is valid, although runtime may select the invalid branch;
  • analyzer loop scopes are discarded, and the typechecker only marks a streaming-response binding when a surviving symbol already exists (:1045-1050). Inside the canonical main loop, out can therefore remain Unknown; a valid file fallback can mask an invalid payload on the actual stream branch.

Example of the latter:

main loop:
    wait for request comes in on s as req
    start streaming response to req with status 200 as out
    store items as [1 and 2]
    store line items as "legacy"
    write line items to out
end loop

At runtime out is definitely a response stream and the list payload is invalid, but the gradual/file branch can make this pass static checking.

Acceptance criteria:

  • Validate definedness and payload type for the concrete branch runtime will select.
  • Preserve/recreate the ResponseStream binding while typechecking main-loop and action bodies.
  • For a gradual target, conservatively validate every viable runtime branch rather than accepting any one valid branch.
  • Cover one-sided undefined leads for both concrete targets, property access, gradual targets, and stream payloads in real handler scope.

4. P1 — merged operands do not compose like normal WFL expressions

parse_write_value_from_lead delegates postfix handling to parse_trailing_postfix (src/parser/stmt/io.rs:22-29). That helper handles brackets and dot/method access, but omits two existing WFL indexing forms handled by the ordinary primary parser: direct integer indexing (src/parser/expr/primary.rs:1301-1341) and at indexing (:1414-1424).

Previously valid classic code now parse-fails:

store line values as ["first" and "second"]
write line values at 0 to "/tmp/out"

write line values 0 to ... is broken similarly.

The same limited helper is used for merged content type and headers operands (src/parser/stmt/web.rs:449-500). The documented <e> clauses therefore reject ordinary call/operator continuations such as:

start streaming response to req with status 200 and content type mime_type of path as out

Acceptance criteria:

  • Reuse a shared continuation parser so merged-command operands support the same postfix/call/operator grammar and precedence as ordinary expressions.
  • Add compatibility tests for [], .field, .method(), at, direct-integer indexing, of, and operator continuations across write, content type, and headers.

5. P1 — flush compatibility preserves only one old behavior

Before this PR, a merged bare name such as flush cache was an ordinary expression statement. The new runtime fallback (src/interpreter/mod.rs:9879-9903) preserves a Function or an Overloaded value containing a zero-argument overload, then otherwise falls through to stream flushing.

This changes previously legal behavior:

store flush cache as 1
flush cache

The base branch evaluates that variable and completes. This head instead tries to flush an undefined cache. An overloaded flush cache action with no zero-argument overload likewise changed from ordinary bare-expression evaluation/no-op to a stream error.

Acceptance criteria:

  • Preserve the complete old expression-statement fallback when the full merged name resolves, not only the zero-argument action happy path.
  • Keep analyzer, typechecker, and runtime resolution rules aligned.
  • Add regressions for a non-callable full-name binding and an overloaded action with no zero-argument overload.

R3 test-strength follow-ups

These are not substitutes for the correctness fixes above, but they are needed to make the Green evidence meaningful:

  • response_stream_backpressure_test must assert the expected timeout/error and a reasonable lower timing bound; it currently discards interpret() and passes on an unrelated immediate exit.
  • outbound_stream_open_expiry_test must assert successful setup/the expected terminal reason and a lower bound; it also currently ignores interpret().
  • Exercise the dropped-run pending-request 500 branch. dropped_interpret_server_cleanup_test starts a streaming response, so it proves body closure but has already consumed the pending sender.
  • Assert that all disconnect clients actually connected, sent, and reached the intended lifecycle point.

Verification note

I could not run new ad-hoc Rust regressions locally because this review environment has no cargo. The existing exact-head Actions run is fully green, but it does not contain these cases. Per the repository’s R3 policy, each behavior change above should land as a Red test-only commit before its Green fix.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions