Skip to content

fix(runtime): harden the queue turn record against a double release and a State panic - #134

Merged
ThomasK33 merged 8 commits into
mainfrom
fix/queue-turn-record-hardening
Oct 29, 2026
Merged

ThomasK33 merged 8 commits into
mainfrom
fix/queue-turn-record-hardening

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

Why

Every open follow-up issue in coder/chat gets one small, reviewed PR, and this one closes #129. An independent review of #124 found three gaps in the queue turn record. A second call to a deferred delivery's release, or a panic from State.AcquireLock while a queue waiter polls, can trip the leaveQueueScope assert, which panics in production. No test read the record, so a missing delete passed every Go test.

What changed

  • dispatch now wraps a deferred delivery's release in sync.OnceFunc, like the release that admit returns. A second call no longer leaves the queue scope again.
  • queueForLock clears its waiter's pending entry in one defer, in place of the two explicit clearPending calls. A panic from State no longer leaves a stale waiter behind the turn record.
  • Three new tests pin the record. The Adapter.ValidateThreadID GoDoc now says that it must not block, the rule that the ConcurrencyQueue GoDoc relies on.

Scope

  • The State-panic case is a DispatchSync case, because net/http recovers a handler panic. A panic in a deferred tail goroutine still ends the process, as on main.
  • Debounce shares queueForLock and gets the same cleanup. Its outcomes do not change.

Tradeoffs

  • The issue offered two places to clear the waiter's entry. I picked the defer in queueForLock, because that function already ends the registration on every other exit, and the panic exit was the one it missed. A clear in leaveQueueScope would need the delivery's event or admission sequence in queueEntry, and the assert there could then never catch a leaked registration. With the new defer removed, the existing queue tests trip that assert.
  • No public path calls the release twice, so the test reaches it through a new export_test.go hook. For that, dispatch builds the release in a new method, deferredRelease.

Blast Radius

Only queue and debounce waiters change. Each waiter takes one more queueMu section when its lock wait ends. An abandoned or failed waiter now clears its entry just after its log line, not just before it.

Verification

  • Each fix lands after its failing test. Before the fix, TestQueueDeferredReleaseCalledTwiceLeavesItsScopeOnce fails with turn records = 0 after one of two deliveries released twice, want 1. TestQueueStatePanicWhileWaiterPollsLeavesNoStaleWaiter fails because the waiter panics with the assert instead of the State's panic, and a later duplicate trips the same assert.
  • With the delete in leaveQueueScope removed, TestQueueIdleScopeRetainsNoTurnRecord fails in both dispatch modes.
  • The three new tests pass 20 of 20 runs under -race. go vet and go test pass in every workspace module, and go test -race passes for the root package.

Closes #129


Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: xhigh

TestQueueIdleScopeRetainsNoTurnRecord reads queueTurns through the new
QueueTurnCount hook. It fails with the delete in leaveQueueScope removed.
…scope twice

dispatch now builds the release in deferredRelease, with no change in
behavior, so the QueueDeferredRelease hook gets the same release.
TestQueueDeferredReleaseCalledTwiceLeavesItsScopeOnce fails: the second
call drops the scope's turn record while another delivery is in flight.
The release that dispatch gives a deferred delivery wraps the idempotent
release from admit, but a second call left the queue scope again. It
could drop the turn record while another delivery was in flight, or trip
the leaveQueueScope assert. sync.OnceFunc makes it idempotent like admit's.
…tale waiter

Under DispatchSync, a panic from State.AcquireLock while a queue waiter
polls skips queueForLock's clearPending. The waiter's leaveQueueScope
then trips its assert in place of the State's panic, and so does a
later duplicate for the scope. TestQueueStatePanicWhileWaiterPollsLeavesNoStaleWaiter
recovers each panic as net/http does and fails on both.
queueForLock cleared the waiter's entry only on the abandoned and failed
outcomes, so a panic from State.AcquireLock left it behind. One deferred
clearPending now covers every exit. On the other exits the entry is
already gone or belongs to a newer waiter, and clearPending leaves it.
ConcurrencyQueue's GoDoc relies on this rule, but only the Observer
interface stated it.
The poll-call constants follow the iota pattern of queue_edge_test.go, and
comments the code already states are gone. The ValidateThreadID GoDoc now
names what a block there costs under ConcurrencyQueue.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-29T21:21:52.361299Z e64f797 Manual request
🔒 Security Review ✅ Completed 2026-10-29T21:22:02.385623Z e64f797 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: bfe6f309ae

ℹ️ 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".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: bfe6f309ae

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

TestQueueStatePanicWhileWaiterPollsLeavesNoStaleWaiter checked only the
turn record count, so a fix that deleted the leaveQueueScope assert
instead of clearing the waiter still passed while the stale waiter stayed
in c.pending. Add QueuePendingCount and assert it is 0 after B's panic.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: e64f797958

ℹ️ 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".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: e64f797958

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 merged commit 0e624ab into main Oct 29, 2026
2 checks passed
ThomasK33 added a commit that referenced this pull request Oct 29, 2026
#138)

## Why

Every open follow-up issue in coder/chat gets one small, reviewed PR, and this one closes #135. Under `DispatchDeferred`, a `State` panic in the prelude unwinds to `net/http`, which recovers it. `dispatchDeferred` freed the admission slot only when the prelude returned, so the panicked delivery kept its slot and its queue turn record, and `Shutdown` timed out.

## What changed

- `dispatchDeferred` frees the slot in a `defer` on every exit that hands no work to a tail, a panic included. The panic still reaches `net/http`.
- The slot release is the `sync.OnceFunc` from #134, so no exit can release twice.
- A new test serves the runtime behind a real `httptest.Server`. It panics `State.AcquireLock` under the queue strategy and `State.MarkEvent` under the burst strategy.

## Scope

- The fix covers every strategy, because the leak was in `dispatchDeferred`. Before the fix, a `MarkEvent` panic leaked the slot under drop, debounce, burst, and concurrent too.
- A panic in a detached tail goroutine still ends the process, as on `main`.
- The panicked event keeps its in-memory acceptance entry, so a redelivery of the same event ID waits until its own request ends. That leak has a different cause and is left as a follow-up.

## Tradeoffs

- A `recover` and re-panic would also free the slot. The `defer` with an ownership flag frees it and leaves the panic value and stack untouched.

## Blast Radius

Only deferred dispatch changes. A delivery that resolves in the prelude now frees its slot as `dispatchDeferred` returns, not just before, which the caller cannot tell apart.

## Verification

- Before the fix, the new test fails in both cases with `Shutdown ... context deadline exceeded`. The queue case also keeps 1 turn record.
- Three wrong fixes fail. A silent `recover` fails the new test with status 200. A release without `OnceFunc` trips the `leaveQueueScope` assert in the queue tests. A release without the ownership flag fails two admission tests.
- The new test and the queue tests pass 20 of 20 runs under `-race`. `go vet` and `go test` pass in every workspace module, and `go test -race` passes for the root package.

Closes #135

---
_Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `xhigh`_
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.

runtime: harden the queue turn record (idempotent release, State panics, retention test)

1 participant