Skip to content

perf(opencode): throttle shell tool metadata streaming - #1049

Merged
Astro-Han merged 3 commits into
devfrom
claude/i1038-shell-throttle
Jun 2, 2026
Merged

perf(opencode): throttle shell tool metadata streaming#1049
Astro-Han merged 3 commits into
devfrom
claude/i1038-shell-throttle

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Summary

Coalesce the shell tool's per-chunk metadata streaming. Previously every decoded chunk fired a full ctx.metadata push, which re-renders the tool part, publishes message.part.updated, drives frontend reconcile, and pushes an ACP in-progress update. For chatty commands (installs, builds, test runners) this is the dominant per-call cost, even though the emitted preview is already capped at 30k chars by preview().

This is PR 3 of the #1038 bash to shell migration (PR 1 = #1046 split, PR 2 = #1048 rename). The throttle plan and a codex design consult live on issue #1038.

Changes

  • New packages/opencode/src/tool/shell-metadata-throttle.ts: a single serialized emit channel. Emits the first non-empty chunk immediately; subsequent chunks coalesce on a 150 ms timer or once accumulated input crosses 4 KiB, whichever fires first. flush("spill") forces an emit; flush("final") is dirty-gated.
  • shell.ts run(): the stream consumer now feeds the throttle instead of calling ctx.metadata per chunk. After the exit/abort/timeout race it joins the consumer fiber and does a final flush inside the process scope, so the throttle's forked timer is still alive and gets interrupted on scope exit. Spill-to-tempfile routes through flush("spill").
  • New test/tool/shell-metadata-throttle.test.ts: 7 deterministic it.effect tests under TestClock (no real sleeps) — first-chunk-sync, byte-threshold, progressive timer flushes, final tail flush, spill force-flush, no-op timer when clean, no-op final when already emitted.

Design (codex consult adopted)

  • Single serialized channel instead of two fibers each calling ctx.metadata. Every emit ships the full preview, so out-of-order writes could let a stale preview overwrite a newer one downstream (the frontend reducer overwrites with the last-arrived full part).
  • Clear dirty/byte counters before awaiting emit so chunks arriving mid-emit are not silently dropped from the next flush.
  • Final flush inside the scope + join the consumer so a buffered tail is not lost when exit wins the race before the stream drains.
  • First chunk stays synchronous: the existing abort-on-output integration tests (shell.test.ts "preserves output when aborted") depend on seeing the first chunk's metadata synchronously.

Behavior preserved

  • Public bash tool id, schema name, and permission key unchanged.
  • Final tool-result metadata still carries the full tail (via completeToolCall).
  • Read-only commands produce no extra capture noise.
  • Spill-to-tempfile, truncation, abort, and timeout semantics unchanged.

Out of scope (deferred)

Verification

  • bun run typecheck — pass.
  • bun test test/tool/ test/permission/ — 656 pass, 1 skip, 0 fail across 34 files.
  • New throttle suite — 7 pass.
  • Abort/timeout integration regression (shell.test.ts) — green.

Refs #1038.

Summary by CodeRabbit

  • Refactor

    • Optimized shell tool metadata updates with interval/byte-threshold throttling to reduce noisy per-chunk updates, ensure timely final emissions, handle spill/truncation cases, and avoid failing the stream when metadata emission errors occur. Also adds a short drain timeout on shutdown to help flush remaining updates.
  • Tests

    • Expanded tests covering throttling, empty-chunk behavior, spill/final flush semantics, timer-driven flushes, and robustness to emit failures.

Every decoded chunk previously fired a full ctx.metadata push, which
re-renders the tool part and publishes message.part.updated. For chatty
commands (installs, builds, test runners) this is the dominant per-call
cost even though the emitted preview is already capped at 30k chars.

Coalesce the pushes through a single serialized channel
(shell-metadata-throttle.ts): emit the first non-empty chunk
immediately, then at most once per 150 ms or once accumulated input
crosses 4 KiB, whichever comes first. Force a flush on spill-to-tempfile,
and drain the consumer + push a final preview inside the process scope on
exit/abort/timeout so no tail is dropped. Serialized emits prevent a stale
preview from overwriting a newer one downstream.

Refs #1038 (PR 3 of the bash -> shell migration).
@Astro-Han Astro-Han added enhancement New feature or request P1 High priority harness Model harness, prompts, tool descriptions, and session mechanics labels Jun 2, 2026
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 30bdce6c-3a5a-4efa-a384-98c4a826e2f7

📥 Commits

Reviewing files that changed from the base of the PR and between 43e5bb9 and 08e42ed.

📒 Files selected for processing (3)
  • packages/opencode/src/tool/shell-metadata-throttle.ts
  • packages/opencode/src/tool/shell.ts
  • packages/opencode/test/tool/shell-metadata-throttle.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/opencode/test/tool/shell-metadata-throttle.test.ts
  • packages/opencode/src/tool/shell-metadata-throttle.ts
  • packages/opencode/src/tool/shell.ts

📝 Walkthrough

Walkthrough

This PR adds an Effect-based metadata throttler (makeMetadataThrottle) that coalesces shell output metadata by time and byte thresholds, integrates it into ShellTool.run to replace per-chunk metadata writes (with explicit spill/final flushes), and expands tests to cover threshold, timer, empty-chunk, and emit-defect scenarios.

Changes

Metadata Throttling Feature

Layer / File(s) Summary
Throttler contract and implementation
packages/opencode/src/tool/shell-metadata-throttle.ts
MetadataThrottleOptions and MetadataThrottle define the API. makeMetadataThrottle implements dirty/bytes-since-flush/first-flushed state, a semaphore-serialized emit path, synchronous first-chunk emission, byte-threshold and interval-driven emissions, "spill" forcing immediate emit, "final" dirty-gated emit, defect-tolerant emit, and a scoped periodic scheduler.
Throttler integration and stream refactor
packages/opencode/src/tool/shell.ts
Adds Duration/Fiber imports and constants for interval/byte thresholds and drain timeout. Instantiates the throttler wired to ctx.metadata, refactors chunk handling to call throttle.onChunk(size) (and flush("spill") on truncation), and on scope exit joins the consumer fiber with a timeout then calls throttle.flush("final").
Throttler test suite
packages/opencode/test/tool/shell-metadata-throttle.test.ts
Test helper and cases validate first-chunk synchronous emission, threshold-triggered immediate flush, timer-driven coalesced emits via TestClock, "final" and "spill" semantics, timer idempotency without new input, empty-chunk no-op preserving first-flush slot, and behavior when emit throws defects (errors swallowed).

Sequence Diagram

sequenceDiagram
  participant Chunk as Chunk Stream
  participant Throttle as Metadata Throttle
  participant Emit as ctx.metadata()
  participant File as Spill File
  Chunk->>Throttle: onChunk(size)
  alt First non-empty chunk
    Throttle->>Emit: emit() sync
  end
  alt Bytes exceed threshold
    Throttle->>Emit: emit() and reset
  end
  alt Output exceeds limit
    Chunk->>File: truncate to file
    Chunk->>Throttle: flush("spill")
    Throttle->>Emit: emit() immediately
  end
  alt Interval timer fires
    Throttle->>Emit: emit() if dirty
  end
  Chunk->>Throttle: flush("final") on scope exit
  Throttle->>Emit: emit() pending tail if dirty
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

task

Poem

🐰 I nibbled bytes and counted time with care,
Batching little whispers floating in the air,
First chunk sings alone, then silence hums along,
Spills shout once, and timers keep the song,
A rabbit's rhythm keeps metadata strong.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'perf(opencode): throttle shell tool metadata streaming' directly and specifically describes the main change—adding throttling to shell tool metadata emissions to improve performance.
Description check ✅ Passed The PR description comprehensively covers all template sections: Summary explains the change and context, Why addresses the problem, Related Issue links #1038, Human Review Status should be set, Review Focus is implicit in the design discussion, Risk Notes states 'Behavior preserved', How To Verify lists test results, and Checklist items are addressed in the description.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/i1038-shell-throttle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested priority: P2 (includes non-doc, non-test paths outside the low-risk bucket).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a metadata throttling mechanism (makeMetadataThrottle) to coalesce streaming metadata pushes from shell commands, reducing the overhead of frequent updates. It integrates this throttle into the shell tool execution path and adds comprehensive unit tests. The review feedback highlights a potential issue where errors during metadata emission could propagate and crash the stream consumer fiber, potentially causing the spawned process to hang. To address this, the reviewer suggested catching and ignoring emission errors and optimizing lock acquisition using a double-checked locking pattern.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/opencode/src/tool/shell-metadata-throttle.ts
Astro-Han added 2 commits June 2, 2026 15:05
…timeout

Address review feedback on #1049:

- Wrap the throttle's emit in Effect.catchDefect so a defect in the
  metadata channel cannot fail the stream consumer (which would stop
  reading stdout and could hang the process) or orDie the final flush.
  Interrupts and typed errors still propagate. emit is typed Effect<void>
  (E = never), so the prior suggestion of Effect.ignore would have been a
  no-op against the defect that is the actual hazard. Added a defect-emit
  test covering the first-chunk, timer, and final flush paths.
- Name the post-exit consumer drain timeout (CONSUMER_DRAIN_TIMEOUT) and
  document its fall-through-to-scope-cleanup semantics.

Refs #1038.
A leading empty decode chunk (TextDecoder can emit "" on a partial
multibyte boundary) would burn the first-flush slot, delaying the first
real output past the timer/threshold. Guard size <= 0 in onChunk.
@Astro-Han
Astro-Han merged commit 31ff1a9 into dev Jun 2, 2026
35 checks passed
@Astro-Han
Astro-Han deleted the claude/i1038-shell-throttle branch June 2, 2026 08:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request harness Model harness, prompts, tool descriptions, and session mechanics P1 High priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant