Skip to content

fix(bridge): pass explicit max_buffer_size to the Claude SDK - #908

Merged
jinon86 merged 2 commits into
mainfrom
fix/claude-sdk-max-buffer-size
Aug 3, 2026
Merged

fix(bridge): pass explicit max_buffer_size to the Claude SDK#908
jinon86 merged 2 commits into
mainfrom
fix/claude-sdk-max-buffer-size

Conversation

@jinon86

@jinon86 jinon86 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

The bridge lost a whole turn on 2026-08-03 18:19:14 KST:

claude_agent_sdk._internal.query - ERROR - Fatal error in message reader:
Failed to decode JSON: JSON message exceeded maximum buffer size of 1048576 bytes

Cause chain (all verified against the installed SDK, claude-agent-sdk 0.2.128):

  • claude_agent_sdk/_internal/transport/subprocess_cli.py:30_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 (1 MiB)
  • same file, 143-146 — when options.max_buffer_size is None, that 1 MiB default is used
  • same file, 972-976 — a single NDJSON line over the limit raises SDKJSONDecodeError, killing the message reader task. There is no recovery path, so the whole turn fails.
  • the bridge never set max_buffer_size anywhere, so it always ran on the 1 MiB default.

The line that actually killed the reader was 1,056,854 bytes — only 8,278 bytes over the limit. A 510 KB PNG screenshot went through the Read tool; Claude Code resized and re-encoded it to 682x2000 (528,000 base64 chars) and then shipped that same base64 twice in one message: once as message.content[0].content[0].source.data and again as toolUseResult.file.base64. That duplication doubles the payload, so the effective failure threshold is only ~524 KB of base64 for a single image — reached routinely by ordinary screenshot/image work.

Change

ClaudeRuntime._build_options now always passes an explicit max_buffer_size. The value is resolved once in __init__, deliberately outside the settings-bound branch, so the bare settings-free construction path (unit tests, the conformance harness, any future caller that forgets to bind settings) cannot fall back to 1 MiB either.

New setting, following the existing alias= convention in bridge/utils/config.py:

Variable Default Range
CCC_CLAUDE_MAX_BUFFER_SIZE 16777216 (16 MiB) 1 MiB – 256 MiB
  • 16 MiB leaves ~16x headroom over the measured fatal line (about 8 images' worth of the duplicated encoding). It bounds a buffer; it does not preallocate memory.
  • The floor is the SDK's own 1 MiB: configuring anything lower is strictly worse than the bug this replaces, so Config rejects it.
  • Malformed / out-of-range values reaching the adapter degrade to the default rather than failing session start — a mistyped buffer bound must not take the bridge down.
  • The default is single-sourced in runtime_config_check.py (stdlib-only, already the home of DEFAULT_PROCESS_TIMEOUT_SECONDS) so config.py and claude_runtime.py cannot drift.

Per repo convention the measured numbers are recorded in the code comments, not just here.

Files

  • bridge/runtime_config_check.pyDEFAULT_CLAUDE_MAX_BUFFER_SIZE / MIN_ / MAX_ + incident rationale
  • bridge/utils/config.pyclaude_max_buffer_size field, alias CCC_CLAUDE_MAX_BUFFER_SIZE
  • bridge/core/claude_runtime.py_resolve_max_buffer_size(); explicit max_buffer_size= on ClaudeAgentOptions
  • bridge/tests/test_claude_max_buffer_size.py — new, 19 tests
  • bridge/.env.example, bridge/README.md, bridge/CLAUDE.md — operator discoverability
  • CHANGELOG.md, bridge/CHANGELOG.md

Tests

New suite covers (a) the default, (b) env override + out-of-range rejection, (c) the value actually reaching ClaudeAgentOptions on both the settings-bound and bare paths, plus a guard pinning the SDK's own 1 MiB constant so the rationale cannot go stale silently.

tests/test_claude_max_buffer_size.py .......  19 passed

full bridge suite (CI command, python 3.12):
python -m pytest -q --cov=telegram_bot --cov-report=term
2169 passed, 1 skipped, 3 warnings, 271 subtests passed in 258.35s
Required test coverage of 80.0% reached. Total coverage: 81.93%

umask-0002 variant: 37 passed
CI hardening regressions:  35 passed
ruff check .   All checks passed!
mypy           Success: no issues found in 81 source files

Source-only change. No live tree, service, or deployment was touched.

🤖 Generated with Claude Code

The Claude adapter never set ClaudeAgentOptions.max_buffer_size, so the SDK
fell back to its own _DEFAULT_MAX_BUFFER_SIZE of 1 MiB
(claude_agent_sdk/_internal/transport/subprocess_cli.py). A single stdout
NDJSON line above that limit raises SDKJSONDecodeError inside the message
reader task, which has no recovery path: the whole turn dies with
"JSON message exceeded maximum buffer size of 1048576 bytes".

Measured on this node 2026-08-03 18:19:14 KST. The fatal line was 1,056,854
bytes -- only 8,278 bytes over the limit. A 510 KB PNG screenshot went through
the Read tool; Claude Code resized and re-encoded it to 682x2000 (528,000
base64 chars) and then shipped that same base64 twice in one message, as
message.content[0].content[0].source.data and again as
toolUseResult.file.base64. The duplication doubles the payload, so the
effective failure threshold was only ~524 KB of base64 for a single image --
routinely reached by ordinary screenshot work.

Every construction path now passes an explicit bound, including bare
settings-free ClaudeRuntime() (unit tests, conformance harness), which would
otherwise remain a route back to 1 MiB. The value is configurable via the new
CCC_CLAUDE_MAX_BUFFER_SIZE (default 16 MiB, accepted range 1 MiB - 256 MiB);
the 1 MiB floor is rejected-below because anything lower is strictly worse
than the SDK default this bound exists to replace. Malformed settings degrade
to the default instead of failing session start.

The default is single-sourced in runtime_config_check.py (stdlib-only, already
the home of DEFAULT_PROCESS_TIMEOUT_SECONDS) so config.py and claude_runtime.py
cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jinon86
jinon86 requested a review from seoseo-ai as a code owner August 3, 2026 09:39

@seoseo-ai seoseo-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after explicit operator authorization using the local seoseo-ai credential.

@jinon86
jinon86 merged commit 7ca16ee into main Aug 3, 2026
8 checks passed
@jinon86
jinon86 deleted the fix/claude-sdk-max-buffer-size branch August 3, 2026 10:07
seoseo-ai added a commit that referenced this pull request Aug 3, 2026
…#909) (#917)

No self-update agent-cron task was ever registered, so a deferred run (bridge
busy) had no scheduled tick to retry on — a node could go days without
receiving merged fixes (gwakga: timer active, task absent, so #908 never
applied). setup.sh now idempotently adds a `self-update` task right after the
self-update hook is installed:

- `add` rejects a duplicate id, so re-runs are a no-op (idempotent).
- Opt out with CCC_SELF_UPDATE_REGISTER_CRON=false.
- Schedule via CCC_SELF_UPDATE_CRON (default `17 4,10,16,22 * * *`).
- --success-exit-codes 0,8,11 so a clean update, a bridge-busy defer (8), and
  a no-services-allowlist degraded run (11) do not raise on-failure alerts;
  only real aborts alert. (Uses the #911 successExitCodes feature.)
- The agent-cron timer is still installed separately; without it the task
  never fires.

docs/self-update.md gains a Scheduling section and stops claiming "the next
scheduled tick retries" without that precondition; it also notes that an
update requested from a live conversation self-defers (its own turn marks the
bridge busy).

Tests: setup.test.sh 69 pass; bash -n clean.

Co-authored-by: Seo Jin On <jinon86@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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