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

> Discover how Read Frog tackles AI stream edge cases and error recovery. Learn about payload validation, settled states, SDK error handling, and keep-alive pings for robust AI streaming.

- Repository: [MengXi/read-frog](https://github.com/mengxi-ream/read-frog)
- Tags: internals
- Published: 2026-03-07

---

**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`](https://github.com/mengxi-ream/read-frog/blob/main/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:

```typescript
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`](https://github.com/mengxi-ream/read-frog/blob/main/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:

```typescript
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`](https://github.com/mengxi-ream/read-frog/blob/main/src/utils/error/extract-message.ts).

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

```typescript
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:

```typescript
// 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`](https://github.com/mengxi-ream/read-frog/blob/main/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`](https://github.com/mengxi-ream/read-frog/blob/main/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`](https://github.com/mengxi-ream/read-frog/blob/main/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`](https://github.com/mengxi-ream/read-frog/blob/main/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.