Bug - Exec WebSocket: stdout produced before the client attaches is dropped (only ~64KB survives)

**TL;DR:** Since ~2026-07-28 19:00 UTC, the non-TTY exec WebSocket (`wss://api.sprites.dev/v1/sprites/{name}/exec`) no longer delivers a command’s complete stdout. Output the guest process produces before the WebSocket client is attached is truncated to roughly the last 64KB, so any fast producer (e.g. `cat` or `tar -cf -` of anything larger than 64KB) arrives with its **head missing**. This silently corrupts every workflow that streams file content over exec stdout. The command’s exit code still reports 0.

## Environment

- API: `https://api.sprites.dev` (exec over WebSocket, non-TTY, `stdin=true`)

  • Client: plain Node 22 (`globalThis.WebSocket`), no SDK — repro attached
  • Started: first failures in our production logs ~2026-07-28 19:00 UTC; before that, the same client code pulled multi-MB tar streams reliably for weeks

## Expected

`exec` of `cat /tmp/f` (f = N bytes of `/dev/urandom`) delivers exactly N bytes on stdout frames, regardless of how fast the process produces them relative to the client attach.

## Actual

Output from the attached `repro.mjs` (creates a throwaway sprite, writes random files in the guest, cats them back over exec stdout, compares md5s; two runs):

```

[test 2] stream integrity: cat a random file over exec stdout

size exit bytes-received intact received==last-64KB-of-file

16KB 0 16384 true true

64KB 0 65536 true true

256KB 0 262144 true false

1024KB 0 65536 false true ← exactly the last 64KB of the file

4096KB 0 2015232 false false ← 2nd run: 1589248 (varies per run)

```

Key rows:

- **1MB file → 65,536 bytes received, and the md5 matches `tail -c 65536` of

the file.** The client got exactly the last 64KB; everything before it was

dropped. Reproduces every run.

- **4MB file → ~1.5–2.5MB received (varies run to run), head missing.** Looks

like: everything produced before attach is gone except the last 64KB of it,

then live streaming is intact from attach onward.

- Exit code is 0 in every case — the loss is undetectable from the exec result.

## Additional observations (same repro, plus variants)

1. The exec endpoint now emits debug/control messages we had never seen before this date, suggesting execs became attachable sessions with a history buffer — which would explain the ~64KB survival window if history is a 64KB ring and pre-attach output only lives there:

```

{“msg”:“session_created cmd=sh”,“pid”:5,“t_ms”:3,“type”:“debug”}

{“msg”:“normal_path history_len=0”,“pid”:5,“t_ms”:3,“type”:“debug”}

{“type”:“session_info”,“session_id”:“5”,“command”:“sh”,“created”:…,“is_owner”:false,“tty”:false}

```

Note `is_owner: false` — for a WebSocket that itself initiated the exec.

2. **stderr is sometimes merged into stdout frames** (intermittent, seems timing-dependent). With `sh -c ‘printf AAAA; printf BBBB 1>&2; printf CCCC’` we have observed a single stdout (stream-id 1) frame carrying `“AAAACCCCBBBB”`. For us this corrupts streamed archives whenever the producer writes any diagnostics to stderr.

3. **Data frames can arrive after the exit (stream-id 3) frame.** A client that treats the exit frame as end-of-stream (ours did) loses the tail as well.

4. Same behavior with `tar -cf -` as the producer: a 10MB tree arrived as

9,781,248 bytes beginning mid-file — no tar header at offset 0, so the archive is unreadable.

## Impact

We sync sandbox workspaces back to our host by streaming `tar -cf -` over exec

stdout. Since the change, **every** workspace pull fails integrity checks

(truncated archive → untar fails), which fails every run of our product on this

backend. Small control execs (probes, short commands) still work, which made

this hard to spot — only bulk streams die.

## Repro steps

```

SPRITES_API_TOKEN= node repro.mjs

```

Node >= 22, no dependencies. The script creates one sprite named

`exec-stream-repro-`, runs the tests above, prints the table, and deletes

the sprite. Runtime is ~1 minute.

## Questions

- Was the exec endpoint recently changed to session-with-history semantics?

- Is there a way (query param, protocol handshake) to make the exec stream

lossless again — i.e. block the process, or buffer fully, until the initiating

client is attached?

- If the session model is here to stay, what is the supported way to stream bulk data out of a sprite?

Happy to share a repro package

It looks like this post was generated with AI tools. The robot was aware that Markdown is helpful for formatting, but unfortunately it seems to have escaped that formatting with backslashes, making a bit of a mess. It’s added new lines inside paragraphs, and code snippets/blocks have failed too.

Thus, if you can edit it for readability, you may get some bites. :fishing_pole: