How Read Frog Handles Stream Edge Cases and Error Recovery Mechanisms for AI Responses

Read Frog ensures robust AI streaming by validating payloads with Zod schemas, enforcing single terminal states via a settled flag, capturing AI-SDK errors through onError callbacks, and maintaining connection liveness with keep-alive pings.

Read Frog is a Chrome extension that streams AI-generated content between content scripts and background workers using Chrome's runtime.Port API. Handling stream edge cases and error recovery mechanisms is critical for maintaining user experience when network interruptions, malformed payloads, or AI service failures occur.

Background-Side Stream Orchestration and Validation

The core stream handling logic resides in src/entrypoints/background/background-stream.ts. This module orchestrates the connection between the AI SDK and the Chrome extension port, implementing multiple defensive layers against edge cases.

Payload Validation with Zod Schemas

Every stream begins with a start message that must conform to strict schemas. The createStartMessageParser function validates incoming payloads using either streamTextPayloadSchema or structuredObjectPayloadSchema depending on the stream type.

If Zod parsing fails, the handler immediately sends an error message with the text "Invalid stream start payload", disconnects the port, and prevents any further processing. This validation occurs before the AI stream is initiated, ensuring that malformed requests never reach the AI provider.

Single-Completion Guards and Terminal State Enforcement

To prevent duplicate terminal messages or resource leaks, createStreamPortHandler maintains three state flags: isActive, hasStarted, and settled. The settled flag acts as a single-completion guard.

The finalize function checks this flag before performing cleanup:

const finalize = (callback: () => void) => {
  if (settled) return
  settled = true
  try { port.disconnect() } catch {}
  callback()
  cleanup()
}

This pattern ensures that exactly one terminal message (done or error) is sent and that the port disconnects only once, even if multiple error conditions trigger simultaneously.

Abort Signal Handling and Cleanup

Each stream creates a dedicated AbortController. If the caller provides an AbortSignal that is already aborted before the stream starts, the handler immediately throws a DOMException with the name "AbortError" and message "stream aborted".

During active streaming, the abort signal is passed to the AI SDK's streamFn. When the signal fires, the onError callback captures the abort as an error, triggering the error recovery path and ensuring the port posts an error message before disconnecting.

The disconnectListener also aborts the controller when the port disconnects unexpectedly, preventing orphaned streams from continuing to consume resources after the connection is lost.

Client-Side Port Streaming and Connection Management

The content script uses src/utils/content-script/port-streaming.ts to wrap the Chrome port in a Promise-based API that handles message correlation, keep-alive mechanics, and abort integration.

Request-ID Correlation and Message Filtering

To prevent message leakage between concurrent streams, each call to createPortStreamPromise generates a UUID using createRequestId. This requestId is attached to every message sent to the background script.

The message listener filters incoming port messages by requestId, ignoring any stray messages from previous or concurrent streams that might arrive due to timing issues or port reuse. This correlation ensures that a stream only resolves or rejects based on its own terminal message.

Keep-Alive Ping Mechanism

Chrome ports can disconnect due to inactivity during long-running AI generations. To prevent this, the client implements a keep-alive ping system:

if (keepAliveIntervalMs > 0) {
  keepAliveTimer = setInterval(() => {
    if (settled) return
    try {
      const pingMessage: StreamPortRequestMessage<unknown> = { type: "ping", requestId }
      port.postMessage(pingMessage)
    } catch {}
  }, keepAliveIntervalMs)
}

By default, keepAliveIntervalMs is set to 20 seconds. The background handler explicitly ignores ping messages until the stream has started, preventing premature activation while still maintaining the connection during active streaming.

Abort Integration and Error Propagation

If the caller provides an AbortSignal, the promise immediately rejects with a DOMException("aborted", "AbortError") if the signal is already aborted. Otherwise, the abort listener attaches to the signal and will reject the promise when triggered.

When the background posts an error message, the client wraps the error payload in a JavaScript Error instance and rejects the promise, propagating the error message to the caller. This ensures that AI-SDK errors, network failures, or validation errors all surface as standard promise rejections in the content script.

AI Error Recovery and Message Normalization

To present meaningful error messages to users, Read Frog implements specialized error extraction logic in src/utils/error/extract-message.ts.

The extractAISDKErrorMessage function handles the various shapes of errors thrown by the AI SDK:

export function extractAISDKErrorMessage(error: unknown): string {
  if (typeof error === "string") return error
  if (typeof error === "object" && error !== null) {
    const src = error as { message?: unknown; responseBody?: unknown; text?: unknown }
    return getNonEmptyString(src.message) ??
           getNonEmptyString(src.responseBody) ??
           getNonEmptyString(src.text) ??
           "Unexpected error occurred"
  }
  return "Unexpected error occurred"
}

This extraction prioritizes the message property, falls back to responseBody or text for raw API responses, and ensures that users always see a human-readable string rather than [object Object] or undefined values. When the background handler captures an error via the onError callback, it uses this utility to normalize the message before posting it to the content script.

End-to-End Implementation Example

The following example demonstrates how these mechanisms work together in practice:

// content-script: request a streamed translation
import { createPortStreamPromise } from "@/utils/content-script/port-streaming"
import { BACKGROUND_STREAM_PORTS } from "@/types/background-stream"

async function translateSelection(text: string, abortSignal?: AbortSignal) {
  const payload = { providerId: "openai-default", prompt: `Translate: ${text}` }

  // Returns the complete translated string when the background finishes
  const translated = await createPortStreamPromise<string>(
    BACKGROUND_STREAM_PORTS.streamText,
    payload,
    { signal: abortSignal, onChunk: chunk => console.log("Chunk:", chunk) }
  )
  return translated
}

Behind the scenes:

  1. createPortStreamPromise generates a UUID requestId and sends a start message to the background port.
  2. The background handler (handleStreamTextPort in src/entrypoints/background/background-stream.ts) validates the payload using Zod schemas. If invalid, it immediately posts an error with "Invalid stream start payload".
  3. Upon validation, the handler creates an AbortController and calls runStreamTextInBackground, passing the abort signal to the AI SDK.
  4. Chunks stream via port.postMessage with type: "chunk". If the AI SDK reports an error via onError, the handler stores it in streamError and surfaces it using extractAISDKErrorMessage.
  5. The client receives messages, filtering by requestId. Keep-alive pings fire every 20 seconds to prevent Chrome from disconnecting the port.
  6. On done, the promise resolves. On error or disconnect, it rejects with the normalized error message. The finalize function ensures the port disconnects exactly once.

Summary

  • Strict validation using Zod schemas in createStartMessageParser prevents malformed payloads from reaching the AI provider, returning "Invalid stream start payload" immediately on failure.
  • Single-completion guarantees via the settled flag and finalize function ensure exactly one terminal message (done or error) is sent and resources are cleaned up once.
  • Abort handling integrates AbortController with Chrome ports, throwing DOMException("stream aborted", "AbortError") when signals fire or ports disconnect unexpectedly.
  • Error prioritization captures the original AI-SDK error via onError callbacks, using extractAISDKErrorMessage to normalize various error shapes into user-friendly strings.
  • Connection resilience uses UUID-based requestId correlation to filter stray messages and keep-alive pings (default 20s) to prevent Chrome from garbage-collecting idle ports.

Frequently Asked Questions

How does Read Frog prevent duplicate terminal messages?

Read Frog uses a settled flag within the finalize function in src/entrypoints/background/background-stream.ts. When a stream completes—whether successfully with done or unsuccessfully with error—the finalize function checks this flag. If already true, it returns immediately without sending additional messages or disconnecting the port again. This guarantees exactly one terminal event per stream even if multiple error conditions trigger simultaneously.

What happens when an AI stream aborts mid-generation?

When an abort signal fires or the port disconnects unexpectedly, the AbortController created in createStreamPortHandler triggers cancellation. The background handler catches this via the onError callback, stores the error in streamError, and posts an error message to the content script before calling finalize. The content script's createPortStreamPromise receives this error and rejects the promise with a JavaScript Error instance, allowing the UI to display the failure reason extracted via extractAISDKErrorMessage.

How are AI-SDK error messages extracted for display?

The extractAISDKErrorMessage function in src/utils/error/extract-message.ts normalizes errors by checking multiple possible shapes. It first checks if the error is a string, then inspects object properties for message, responseBody, or text fields. It returns the first non-empty string found, or falls back to "Unexpected error occurred". This ensures users see meaningful descriptions rather than [object Object] or undefined values, regardless of how the AI SDK structures its errors.

Why does Read Frog use keep-alive pings for Port connections?

Chrome's runtime.Port connections can be garbage-collected or disconnected by the browser if no messages flow for extended periods, which commonly occurs during long-running AI generations. To prevent silent disconnects, createPortStreamPromise in src/utils/content-script/port-streaming.ts sends ping messages every 20 seconds (configurable via keepAliveIntervalMs) while the stream is active. The background handler ignores these pings until the stream starts, ensuring the connection stays alive without interfering with the actual data flow.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →