Skip to content

fix(binding-kafka): reconnect KIND_API_REQUEST pool after idle teardown - #2544

Merged
jfallows merged 5 commits into
developfrom
claude/zilla-issue-2532-t590ti
Sep 4, 2026
Merged

fix(binding-kafka): reconnect KIND_API_REQUEST pool after idle teardown#2544
jfallows merged 5 commits into
developfrom
claude/zilla-issue-2532-t590ti

Conversation

@jfallows

@jfallows jfallows commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes the deeper, more fundamental hang described in this comment on #2532, re-traced after #2540 was merged.

Root cause: KafkaClientApiFactory.KafkaApiClient (backing every KIND_API_REQUEST client, including binding-mcp-kafka's KafkaApiResetOffsetsClient and friends) pools a single network connection per affinity across many independent Kafka API requests over time. When that connection is reset or aborted while idle (no request in flight, none queued — e.g. the south TCP connection silently drops after being idle), cleanup only OR'd the INITIAL_CLOSED/REPLY_CLOSED bits into state, without ever clearing the sticky INITIAL_OPENING/REPLY_OPENED bits left over from when the connection was first opened. state ends up simultaneously "opened" and "closed".

The next, unrelated caller's enqueue() only checked the opening/opened bits (never closed), so it took the "connection is still usable" branch and wrote its request straight onto the dead stream — no fresh BEGIN was ever sent, and no response (success or error) ever arrived, hanging the caller indefinitely. This matches the wire trace in the linked comment exactly: a DATA frame reusing an already-reset stream id with no preceding BEGIN.

Fix: onNetEnd()/onNetAbort()/onNetReset() (and the internal cleanupNet()/cleanupNetPending() teardown paths used by decode-error handling) now each converge on a single onNetClosed() once both directions are confirmed closed (KafkaState.closed(state)). onNetClosed() fails the app-side backlog, releases the decode/encode slots and budget debitor, resets the connection's bookkeeping (sequence numbers, decoder, correlation id) to a clean slate, and schedules the reconnect — restoring the invariant that a torn-down connection's state never lingers as "open" for a later caller to trip over. doNetBegin()'s own now-redundant reset branch is removed, and enqueue() reverts to its original, simpler form since state is trustworthy everywhere again.

Test coverage added:

  • specs/binding-kafka.spec: new create.topics.v7.idle.reset.reconnect scenario (application + network scripts, using notify/await barriers to force the idle-teardown-then-new-request ordering deterministically), plus a network-layer peer-to-peer self-consistency IT (KafkaCreateTopicsIT#shouldReconnectAfterIdleConnectionReset).
  • runtime/binding-kafka: KafkaCreateTopicsIT#shouldReconnectAfterIdleConnectionReset — reproduces the exact hang against a live engine (confirmed as a failing/timing-out test against the pre-fix code) and confirms the fix.

All 47 IT classes in runtime/binding-kafka (218 unit tests, all integration tests including SASL, cache, fetch/produce, group, and every existing reconnect scenario) pass with no regressions. Checkstyle and license checks are clean on both changed modules.

Fixes #2532

🤖 Generated with Claude Code

https://claude.ai/code/session_01XJZqfbhycCM4bTy2u2Z7qc


Generated by Claude Code

KafkaApiClient pools a single network connection per affinity across many
independent Kafka API requests. When that connection was reset or aborted
while idle (no request in flight, none queued), cleanup only OR'd the
CLOSED bits into state without clearing the sticky OPENING/OPENED bits, so
the next unrelated request's enqueue() saw a connection that looked both
open and closed, took the "still open" branch, and wrote its request onto
the dead stream. No BEGIN was ever resent and no response ever arrived,
hanging the caller indefinitely.

Each of onNetEnd/onNetAbort/onNetReset, and the internal cleanupNet/
cleanupNetPending teardown paths, now converge on a single onNetClosed()
once both directions are confirmed closed: it fails the app-side backlog,
releases decode/encode slots and budget, resets the connection's
bookkeeping to a clean slate, and schedules the reconnect — restoring the
invariant that a closed connection's state never lingers as "open" for a
later caller to trip over.

Fixes #2532

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJZqfbhycCM4bTy2u2Z7qc
@jfallows
jfallows force-pushed the claude/zilla-issue-2532-t590ti branch from 9e8eb5b to 942e931 Compare September 4, 2026 17:06
claude and others added 4 commits September 4, 2026 17:57
doNetEnd/doNetAbort/doNetReset are always the last mutation for whichever
direction they close, and any preceding direct state assignment in a
caller already happened before they run — so checking KafkaState.closed(state)
at the end of each of them is safe for every caller, not just the three
onNetXxx handlers that already check explicitly. This covers cleanupNet()
and cleanupNetPending() (the internal decode-error/SASL-failure teardown
paths), which previously needed their own trailing check to get the same
guarantee and no longer do.

The onNetXxx handlers keep their own explicit check too, so onNetClosed()
is evaluated twice on that path (once inside doNetEnd/Abort/Reset, once in
the caller) — harmless, since the first evaluation resets state to 0,
making the second trivially false.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJZqfbhycCM4bTy2u2Z7qc
…reset-reconnect

The new create.topics.v7.idle.reset.reconnect scenario had a client.rpt
under streams/application/api/ but no matching server.rpt or peer-to-peer
IT method, leaving the pairing/self-consistency check specs/AGENTS.md
requires for every scenario unmet (an existing sibling scenario,
create.topics.v7.reconnect.no.probe, carries the same pre-existing gap,
left alone here as out of scope).

The app-layer server.rpt also emits the CONNECTION_RESET notify the
shared client.rpt awaits before its second connect, since notify/await
barriers are session-wide: in the runtime IT that signal comes from the
network script after the simulated idle-connection reset, but the pure
app-layer peer test has no network side, so the server script provides it
instead to unblock the second exchange.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJZqfbhycCM4bTy2u2Z7qc
…ng apiRequest BEGIN

KafkaApiStream.onAppWindow marked the reply as both opening and opened
via KafkaState.openedReply(state) whenever it received a reply-window
grant, on the assumption a window could only arrive after this stream's
own doAppBegin had already sent BEGIN. A proactive reply-window grant
(originating from the http server binding's early-window optimization
and propagating down through mcp/mcp-kafka/kafka) can now arrive before
the real response does, so REPLY_OPENING was getting set before
doAppBegin ever ran. doAppBegin's `if (!KafkaState.replyOpening(state))`
guard then saw the bit already set and silently skipped sending BEGIN,
while the response DATA/END still went out - leaving reset_offsets (and
any other multi-stage apiRequest chain, e.g. FindCoordinator ->
DescribeGroups) hung forever waiting for a BEGIN that would never come.

Adds KafkaState.openReply(state), setting REPLY_OPENED alone, matching
the independent opening/opened bit pattern HttpState already uses (see
HttpClientFactory/HttpServerFactory). onAppWindow now uses openReply
instead of the combined openedReply, so a window grant only ever
records flow-control progress and never implies BEGIN was sent -
doAppBegin remains the sole setter of REPLY_OPENING.

Also adds a network-level regression test exercising the
FindCoordinator -> DescribeGroups chain with api.versions negotiation
enabled (the production default) rather than disabled, since every
existing test in this class ran with api.versions off and so never
exercised this pooled-connection request-dispatch path.

Fixes #2532

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jfallows
jfallows merged commit d5e17e1 into develop Sep 4, 2026
43 checks passed
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.

mcp-kafka: reset_offsets hangs forever when target consumer group state is Dead

2 participants