Skip to content

fix(kotlin): keep the SSE flow alive when a deep payload overflows the parser stack - #2502

Open
NathanTarbert wants to merge 3 commits into
mainfrom
fix/kotlin-sse-stackoverflow-2442
Open

fix(kotlin): keep the SSE flow alive when a deep payload overflows the parser stack#2502
NathanTarbert wants to merge 3 commits into
mainfrom
fix/kotlin-sse-stackoverflow-2442

Conversation

@NathanTarbert

Copy link
Copy Markdown
Contributor

The Kotlin SDK's SSE parser now survives a payload deep enough to overflow the parser's stack, instead of the flow being torn down under the collector.

Thanks @ez-lbz. The report was precise about why the existing guard misses this, which is the whole bug.

Sequence

SseParser.parseFlow wraps json.decodeFromString in catch (e: Exception). A deeply nested payload raises StackOverflowError, which is an Error, not an Exception, so it goes straight past the handler and out of the flow.

Change

A second catch (e: Error) arm alongside the existing one.

This started as catch (e: Throwable) with a CancellationException rethrow, and mutation testing is what changed it: deleting that rethrow failed no test, so it was an untested guard sitting in the patch. catch (e: Error) is narrower, lives in the Kotlin common stdlib so it stays multiplatform-safe (unlike StackOverflowError, which is JVM-only), and structurally cannot swallow CancellationException, since that is an Exception. Nothing in the final patch is uncovered.

Tests

New SseParserTest.parseFlow_survivesDeeplyNestedJson sends a 100,000-deep nested array followed by a valid TextMessageStartEvent. Before the change it fails with java.lang.StackOverflowError at StringJsonLexer.kt:43, matching the reported trace.

Full jvmTest across all three modules after the change: core 125, client 133, tools 11, so 269 tests, 0 failures. Baseline :kotlin-client:jvmTest was 132, 0 failures. OpenJDK 21.0.12.1, matching the java-version: "21" in .github/workflows/publish-kotlin-sdk.yml; Gradle 8.14.2 via the committed wrapper.

Mutation-checked: narrowing to Exception fails the new test, deleting the catch (e: Error) block fails it, and narrowing Error to NotImplementedError fails it.

Verified on the JVM target only. The change is in commonMain and Error is common stdlib, so it compiles everywhere, but the Native, JS and iOS targets were not built or run locally.

On the rest of the issue

This covers the Kotlin item only. The Java, Dart and Rust items in #2442 sit with different owners and different toolchains, and I have suggested on the issue that it be split per SDK so none of them blocks the others.

Checklist

  • Failing test written first
  • Fix makes it pass
  • Full JVM suite passes
  • Build succeeds

Refs #2442

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1787767693 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1787767693' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1787767693' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1787767693' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1787767693' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1787767693' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1787767693' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1787767693

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 87a140a

@pkg-pr-new

pkg-pr-new Bot commented Aug 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2502

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2502

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2502

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2502

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2502

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2502

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2502

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2502

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2502

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2502

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2502

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2502

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2502

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2502

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2502

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2502

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2502

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2502

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2502

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2502

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2502

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2502

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2502

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2502

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2502

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2502

commit: 1c723ca

@contextablemark contextablemark 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.

There is one portability and safety blocker here.

The new catch (e: Error) is in commonMain, but it solves a JVM-specific failure mode by swallowing the entire platform Error hierarchy. On the JVM that includes fatal conditions such as OutOfMemoryError, linkage failures, and VM/internal errors, so the SSE parser could silently drop a payload and continue after the process is already compromised.

The regression test has the same mismatch: it lives in commonTest, but assumes 100,000 levels of recursion produce a recoverable StackOverflowError. Kotlin/Native does not expose that JVM exception; overflowing its native stack can terminate the test process instead, and the release allTests path includes iOS targets.

Please avoid catching all Errors in shared code. Either reject excessive nesting in a platform-neutral way and test that from commonTest, or isolate narrowly caught JVM overflow handling and its regression test in the JVM source sets.

catch (e: Error) is gone from commonMain. On the JVM it took the whole platform
Error hierarchy, so an OutOfMemoryError or a linkage failure would be logged as
a parse failure and the stream would carry on from a process that is already
compromised. And it did nothing for the targets that need it most: Kotlin/Native
has no JVM StackOverflowError, and overflowing its stack can end the process
with nothing to catch.

The nesting is counted before the deserializer is handed the payload, which is
the only form of this check that means the same thing on every target. The scan
is iterative, because a recursive depth check fails the way the parser it
protects fails, and it tracks string state with escapes so that brackets inside
a string are data rather than structure.

MAX_JSON_DEPTH is 512: far above the few levels the protocol emits and above the
arbitrary customer JSON that rides in state snapshots, far below what threatens
any target's stack.

Tests moved off the JVM assumption and now assert behaviour that holds
everywhere: an over-nested payload is dropped and the stream keeps delivering, a
64-level payload is accepted, and brackets inside a string do not count.

That middle test is deliberately not written against MAX_JSON_DEPTH. Derived
from the constant it scales with it and passes for any value, including one too
low to carry real payloads; mutation-testing caught that, since setting the
limit to 2 failed nothing until the depth was made absolute.

jvmTest 271 across the three modules, 0 failures, on OpenJDK 21.0.12.1 matching
the workflow's java-version. compileKotlinIosSimulatorArm64 succeeds, so the
common code builds for a Native target. iOS tests could not be run here: the
test link step needs full Xcode and only CommandLineTools is installed, so CI's
allTests is the first place those execute.

Refs #2442
@NathanTarbert

Copy link
Copy Markdown
Contributor Author

Hey @contextablemark,

You were right on both halves, and the portability one is the more serious of the two: the patch solved a JVM failure mode in shared code and did nothing for the targets where the same payload is worst. Kotlin/Native has no JVM StackOverflowError, and overflowing its stack can end the process with nothing to catch, so catch (e: Error) was buying safety only where it was least needed while swallowing OutOfMemoryError and linkage failures where it was.

I took your first option. The nesting is counted before the deserializer is handed the payload, which is the only shape of this check that means the same thing on every target, and it lives in commonMain with its tests in commonTest for the same reason.

Two details in the implementation that are there deliberately. The scan is iterative, because a recursive depth check fails exactly the way the parser it protects fails. And it tracks string state with escape handling, so brackets inside a string are data rather than structure — without that, a message whose text happens to contain a run of [ would be dropped as though it were an attack. There is a test for that case.

MAX_JSON_DEPTH is 512, chosen to sit far above what the protocol emits and above the arbitrary customer JSON that rides in state snapshots and deltas, and far below what threatens any target's stack. Happy to move it if you have a number in mind.

Worth reporting one thing mutation-testing caught, because it is the kind of test that looks like coverage and is not. My "accepts nesting up to the limit" case was written as MAX_JSON_DEPTH - 1, so it scaled with the constant and passed for any value of it — setting the limit to 2 failed nothing. It now asserts an absolute 64 levels, which is a claim about real payloads rather than a restatement of the constant, and a wrong limit fails it.

On verification, and being precise about the boundary rather than claiming more than I ran. jvmTest is 271 across the three modules with 0 failures, on OpenJDK 21.0.12.1 matching the java-version in the workflow. compileKotlinIosSimulatorArm64 succeeds, so the common code does build for a Native target — which is more than the previous version of this PR could say. I could not execute the iOS tests: the test link step needs full Xcode and this machine has only CommandLineTools, so xcrun fails during linkDebugTestIosSimulatorArm64. CI's allTests is the first place those actually run, and given that path includes the iOS targets you flagged, that is the check worth watching on this PR.

Mutations, each killed: disabling the guard fails the over-nesting test, ignoring string state fails the brackets-in-strings test, and setting the limit to 2 fails the 64-level test.

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.

2 participants