schollz/croc · error · Error

Relay stream did not start with croc framing

Error message

Relay stream did not start with croc framing

What it means

FrameDecoder.push() validates that the first 4 buffered bytes of each frame equal the croc magic ('c','r','o','c'). If they differ, the byte stream is not croc framing and the decoder clears its buffer and throws. This is the first integrity gate on data arriving from the relay.

Source

Thrown at web/src/protocol/framing.ts:29

  frame.set(MAGIC, 0);
  new DataView(frame.buffer).setUint32(4, payload.byteLength, true);
  frame.set(payload, 8);
  return frame;
}

export class FrameDecoder {
  private buffer = new Uint8Array();

  push(chunk: Uint8Array) {
    this.buffer =
      this.buffer.byteLength === 0 ? chunk.slice() : concatBytes(this.buffer, chunk);
    const messages: Uint8Array[] = [];

    while (this.buffer.byteLength >= 8) {
      for (let index = 0; index < MAGIC.byteLength; index += 1) {
        if (this.buffer[index] !== MAGIC[index]) {
          this.buffer = new Uint8Array();
          throw new Error("Relay stream did not start with croc framing");
        }
      }
      const length = new DataView(
        this.buffer.buffer,
        this.buffer.byteOffset,
        this.buffer.byteLength,
      ).getUint32(4, true);
      if (length > MAX_FRAME_SIZE) {
        this.buffer = new Uint8Array();
        throw new Error(`Relay frame is too large (${length} bytes)`);
      }
      if (this.buffer.byteLength < length + 8) break;
      messages.push(this.buffer.slice(8, length + 8));
      this.buffer = this.buffer.slice(length + 8);
    }

    return messages;
  }

View on GitHub (pinned to e25f1bdc04)

Solutions

  1. Inspect the first bytes of the rejected chunk (hex-dump the stream) to identify what protocol actually answered — usually an HTTP/proxy response indicating the wrong relay address.
  2. Verify the relay URL and scheme (wss vs wss-less, port) match a croc relay that speaks the framed protocol.
  3. Ensure you only push bytes received after the relay connection is fully established and no handshake preamble is prepended.
  4. After any prior framing error, recreate the FrameDecoder (its buffer is cleared) rather than continuing with the same connection.

Example fix

// before
const decoder = new FrameDecoder();
ws.onmessage = (e) => pushAll(decoder, e.data); // mixed text/binary stream
// after
ws.binaryType = "arraybuffer";
const decoder = new FrameDecoder();
ws.onmessage = (e) => pushAll(decoder, new Uint8Array(e.data));
Defensive patterns

Strategy: try-catch

Validate before calling

const MAGIC = new Uint8Array([0x63, 0x72, 0x6f, 0x63]);
function startsWithCrocFraming(chunk: Uint8Array): boolean {
  return chunk.length >= 4 && MAGIC.every((b, i) => chunk[i] === b);
}

Try / catch

try {
  for (const msg of decoder.push(chunk)) handle(msg);
} catch (error) {
  if (error instanceof Error && /did not start with croc framing/.test(error.message)) {
    await reconnectRelay(); // decoder buffer was cleared; restart the stream
    return;
  }
  throw error;
}

Prevention

When it happens

Trigger: Pushing chunks from a WebSocket/relay stream whose initial bytes are not the croc magic: an HTTP error body or proxy greeting read as binary, a wrong relay URL serving a different protocol, TLS/plain mismatch, or a stream that desynced after an earlier framing bug. The check runs for every frame, so garbage at any frame boundary also triggers it.

Common situations: Connecting to a relay URL that returns an HTML error page; a reverse proxy or captive portal injecting non-binary data; accidentally piping a croc TCP stream and an HTTP handshake into the same socket; feeding the decoder a partial frame offset by stray bytes after a previous decode error; feeding it raw (unframed) croc protocol bytes.

Related errors


AI-assisted analysis of schollz/croc@e25f1bdc04 (2026-08-15). Data as JSON: /api/errors/12e7d46941bc3dea. Report an issue: GitHub.