Skip to content

PTY has no flow control: yes command pegs the server, grows memory without bound, and starves input #53473

Open

Description

@avarayr

Description

Repro (v2.0.23, macOS arm64):

  1. Start opencode serve, then POST /api/pty and connect a client to /api/pty/:id/connect.
  2. Send yes\n as input.

Within a few seconds the server sits at about 150% CPU and its memory grows past 2–7 GB. Ctrl-C (\x03) sent over the socket gets no response within 30 seconds. After the client process exits, the server stays running and orphaned, still at full CPU, and so does yes. That happens even for serve --stdio, whose stdin is closed when the client exits.

Causes (packages/core/src/pty.ts, packages/server/src/handlers/pty.ts):

  1. Copying the retained buffer on every chunk. session.buffer += chunk, followed by session.buffer.slice(excess) once the buffer passes BUFFER_LIMIT (2 MB). From then on, every chunk copies about 2 MB. Under a flood that becomes the main CPU cost. Suggested fix: keep retained output as a list of chunks with a running length, and drop whole chunks from the front (slice only the first one).

  2. No backpressure to the PTY. onData pushes into each subscriber's Queue.unbounded outbox. Nothing slows the PTY down when a socket can't keep up, so memory grows without limit. Suggested fix: cap each subscriber's outbox. Past a high-water mark, pause reading from the PTY and resume once the queue drains. VS Code and xterm.js handle this with byte acknowledgements from the client. Dropping a slow subscriber, or making it resync from the buffer, also works.

  3. Input starved. With the event loop busy relaying output, input from the socket is handled too late to interrupt anything. Fixing 1 and 2 should cover this, but it's worth a test: run yes, send Ctrl-C, and expect the prompt back within a second.

  4. Orphaned server and children. A flooded serve --stdio misses stdin EOF and keeps running with its PTY children. On stdin EOF, the server should kill its PTY processes and exit, even when busy.

Smaller gaps:

  • Attaching to an exited PTY closes with 4404 session exited, even though its output is retained (pty.list documents exited sessions as retained). Clients can't show the last output of a finished command.
  • PTYs belong to a location, and a location is evicted 60 minutes after its last session event (location-activity.ts). PTY traffic doesn't count, so a terminal in a folder with no agent activity is killed without warning.
  • A PTY inherits the server's full environment, and there's no plugin hook like shell create.before to clean it. Clients have to wrap the command to remove server-only variables and credentials.
  • Pty.CreateInput has no size field, so every client needs a second request to set rows and columns.

Plugins

No response

OpenCode version

2.0.23

Steps to reproduce

  1. open OpenCode TUI
  2. launch a session
  3. open integrated terminal
  4. type yes and enter
  5. observe the balloon

Screenshot and/or share link

No response

Operating System

macOS 26

Terminal

ghostty

Activity

  1. github-actions commented on Oct 6, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • #50699: opencode serve leaves orphaned PTY shell processes when PTY connection is denied; this issue also reports orphaned PTY children, though the trigger differs.
  2. opencodejr commented on Oct 27, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I reproduced this on 2.0.23 using the server API, and it still happens on the latest dev (f7d0ce1).

    The script below creates a PTY with POST /api/pty, connects over the WebSocket, sends yes, and samples the server process for 10 seconds:

    • Memory: on 2.0.23 the server grew from about 270 MB to about 860 MB, and on dev to about 1 GB.
    • Output: in those 10 seconds the client received only 5 MB (2.0.23) and 0.5 MB (dev) of output, so the rest was piling up on the server.
    • Ctrl-C: after Ctrl-C, a follow-up echo took about 20 s to come back on 2.0.23 and about 12 s on dev. The server stayed around 100% CPU afterwards.

    This matches your first three points: the retained buffer is re-sliced on every chunk once it passes 2 MB, and output goes into an unbounded queue for each subscriber with nothing slowing the PTY down. I didn't reproduce the orphaned serve --stdio case: in my non-stdio test, yes exited once the client closed. The smaller gaps you listed (attaching to exited PTYs, location eviction, environment cleanup, size on create) read more like separate feature requests.

    The linked issue search didn't work during triage, so I couldn't check for existing duplicates.

    Reproduction script (pty-flood.ts)
    // Reproduces #53473: `yes` in a PTY floods the v2 server (CPU, memory) and starves Ctrl-C.
    // Usage: start `opencode serve --port 4199` in a project dir, then:
    //   OC_PASSWORD=<server password> OC_DIR=<project dir> SERVER_PID=<pid> bun pty-flood.ts
    const base = process.env.OC_URL ?? "http://127.0.0.1:4199"
    const dir = process.env.OC_DIR ?? process.cwd()
    const auth = "Basic " + btoa(`${process.env.OC_USER ?? "opencode"}:${process.env.OC_PASSWORD ?? ""}`)
    const loc = `location%5Bdirectory%5D=${encodeURIComponent(dir)}`
    const pid = process.env.SERVER_PID
    
    const rss = async () => {
      if (!pid) return "?"
      const out = await Bun.$`ps -o rss=,pcpu= -p ${pid}`.nothrow().text()
      const [kb, cpu] = out.trim().split(/\s+/)
      return `rss=${Math.round(Number(kb) / 1024)}MB cpu=${cpu}%`
    }
    
    const created = await fetch(`${base}/api/pty?${loc}`, {
      method: "POST",
      headers: { authorization: auth, "content-type": "application/json" },
      body: JSON.stringify({ command: "/bin/bash", args: ["--norc", "--noprofile"] }),
    }).then((r) => r.json() as any)
    const id = created.data?.id ?? created.id
    console.log("created pty", id)
    
    const ws = new WebSocket(`${base.replace("http", "ws")}/api/pty/${id}/connect?${loc}`, {
      headers: { authorization: auth },
    } as any)
    let bytes = 0
    let tail = ""
    ws.binaryType = "arraybuffer"
    ws.onmessage = (e) => {
      const s = typeof e.data === "string" ? e.data : new TextDecoder().decode(e.data)
      bytes += s.length
      tail = (tail + s).slice(-200)
    }
    ws.onclose = (e) => console.log("closed", e.code, e.reason)
    await new Promise((r) => (ws.onopen = r))
    console.log("connected; baseline", await rss())
    ws.send("yes\n")
    for (let i = 1; i <= 10; i++) {
      await Bun.sleep(1000)
      console.log(`t=${i}s received=${(bytes / 1e6).toFixed(1)}MB`, await rss())
    }
    console.log("sending Ctrl-C")
    ws.send("\x03")
    ws.send("echo MARKER_$((40+2))\n")
    const t0 = Date.now()
    while (Date.now() - t0 < 30000) {
      if (tail.includes("MARKER_42")) break
      await Bun.sleep(100)
    }
    const ok = tail.includes("MARKER_42")
    console.log(ok ? `prompt back after ${Date.now() - t0}ms` : "NO prompt within 30s after Ctrl-C", await rss())
    ws.close()
    await Bun.sleep(3000)
    console.log("after client close", await rss())
    console.log("yes processes still running:", (await Bun.$`pgrep -x yes`.nothrow().text()).trim().split("\n").filter(Boolean).length)
    process.exit(0)

    Run it: bun pty-flood.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions