Skip to content

fix: cancel-then-send hang, turn contamination, abort storm, and thought-level root cause - #48

Merged
william0wang merged 9 commits into
mainfrom
refactor/session-turn
Aug 16, 2026
Merged

fix: cancel-then-send hang, turn contamination, abort storm, and thought-level root cause#48
william0wang merged 9 commits into
mainfrom
refactor/session-turn

Conversation

@william0wang

Copy link
Copy Markdown
Owner

Summary

Fixes the long-standing cancel-then-send hang and a family of bugs uncovered while fixing it, plus the thought-level dropdown regression. 8 commits, all verified against the live backend with an end-to-end probe.

Root cause of the original hang (cancel → send)

The backend holds a prompt lock that stays taken for 18–41s after session/stop (streaming-connection cleanup). The old code guessed readiness from bridge-local signals instead:

  1. preemptInFlightTurn spun waiting for the old turn's pendingTurns deletion — blocked the new prompt for the whole backend finalisation window.
  2. session/send was one-shot: a busy reject (code 1308) immediately threw, so the new message "disappeared".
  3. A 25s magic-window fast-fail killed live turns that happened to start within the recovery window.

Fix: the backend's prompt lock is the single readiness signal. session/send now retries every 500ms (30s cap) while busy; preempt returns immediately; the magic window is gone. Send (command channel) and event ingestion (reader loop) are fully decoupled.

Turn-boundary contamination (found in review)

Removing the old silent-drain let a cancelled turn A's events leak into the next turn B's listener queue. Fixed with a turn-attribution gate (drop pre-turn.started events, only when this send preempted another) plus per-message replay dedup. Review hardening (af335fe):

  • Gate moved before translate() — the translator's turnDone/turnFailed flags are sticky; translating residue made turn B exit at its own turn.started with zero output.
  • preemptInFlightTurn now cancels all matching turns (mirrors cancel()); the first-match break could leave a live turn running and fail the third prompt after 30s of busy retries.
  • fetchLastReply registers its message id so the completion-diff replay can't double-display the same text.

Abort storm (event-loop starvation)

requestWithTimeout leaked a 100ms cancel-poll setInterval after Promise.race settled — N orphan timers per turn warned 10×/s each and starved the loop. Now every racer registers a disposer; the race winner tears them all down. Cancelled turns also drain+decline server requests inline instead of racing the cancel-poll against the editor.

Thought level showed enabled/disabled instead of max/high/low

Two sibling bugs dropped the per-model reasoning definition (variants → protocol levels):

  • buildProviderRegistry compressed models to bare {modelId} and pushed after session/create — new sessions fell back to the apiFormat's 2-state default. Now the registry carries full model definitions (label/contextWindow/maxOutputTokens/reasoning) and is pushed before create and before resume.
  • buildRuntimeModel (resume overlay + /model switch) had the same compression — a resumed session silently reset to 2-state. Both paths now share buildModelElement.
  • Pending-session fallback and /thought hint updated to max|high|low (GLM-5.3 variants).

Also: default model constants (GLM-5.3) extracted to options.ts; stale preempt-wait comments corrected.

Verification

  • 473 tests pass (28 files), including new regression tests for gate placement, full-preempt, and replay dedup.
  • Live-backend end-to-end probes: registry+create and registry+resume overlay both land the session on builtin:bigmodel-coding-plan/GLM-5.3 with thoughtLevel = [high, low, max].
  • Manual: cancel → immediate send now lands after the backend lock releases (~10–20s) instead of erroring or stalling 80–120s; cancelled-but-still-producing turns keep displaying their output.

Relationship to recently merged PRs

Rebased on top of #43 / #46 / #47 (all merged). Division of labour:

  • fix: derive the pending-session current model from the enabled provider #47 (merged): pending-session display — derives the thought vocabulary and current model from the enabled provider's config at advertise time. This branch keeps those changes untouched.
  • This branch: the root cause for materialised sessions — the backend never received the per-model reasoning definitions, so session/read returned the apiFormat's 2-state default (enabled/disabled) no matter what the client advertised. Fixed by carrying full model definitions (label / contextWindow / maxOutputTokens / reasoning levels) in workspace/updateProviderRegistry (pushed before create/resume) and in the runtimeModel overlay (resume + /model switch), via a shared buildModelElement.

Both halves are needed: without this branch, #47 alone still showed enabled/disabled on live sessions.

Conflict resolution during rebase deliberately took #47's side for every pending-display piece (CONFIG_META fallback, bugfixes/dispatch assertions) and kept only the backend-side root-cause fixes here.

@william0wang
william0wang merged commit 561f4f5 into main Aug 16, 2026
1 check passed
@william0wang
william0wang deleted the refactor/session-turn branch August 16, 2026 03:46
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