Skip to content

POST /execute returns empty output on Linux: PTY buffer not drained when child exits (EIO) #145

Description

@Enand-lab

Context and disclosure (AI-assisted analysis): I use open-terminal as the command-execution backend for an AI coding agent (Open WebUI integration) in my self-hosted setup; the bug surfaced precisely because the agent could not see the output of its own commands. The root-cause analysis and the proposed patch below were produced by that AI agent — I don't have deep enough expertise in PTY internals to fully evaluate them on my own. What I can attest to personally: I ran every reproduction command shown below from inside the container and corroborated all reported behavior, and the workaround has been working flawlessly in my daily use. I am posting this because the findings are specific, verifiable, and reference exact lines of the 0.11.34 source — but please treat the analysis itself as AI-generated and review accordingly.

Summary

On Linux (Unix PTY backend), POST /execute returns "output": [] for commands that produce output, even though the response reports status: "done" and the correct exit_code. This happens because the PTY reader in PtyRunner.read_output breaks out of its loop on OSError (EIO, raised when the child exits) without draining the data still buffered in the PTY master FD.

  • Version: open-terminal 0.11.34 (latest on PyPI)

  • Environment: Linux 6.x, Docker container, Python 3.12, single-user mode

Possibly related to #135 (same symptom of empty output), but the root cause there appears different: that report is on Windows (WinPTY backend) with a command that never terminates. This issue is specific to the Unix PTY reader losing buffered output when the child process exits.

Minimal reproduction

# 1. Fast command -- output lost
curl -s -X POST "http://localhost:8000/execute?wait=3" \
  -H "Authorization: Bearer $OPEN_TERMINAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"command":"echo hello"}'

Response:

{
  "id": "20260728-093521-7b41ff",
  "command": "echo hello",
  "status": "done",
  "exit_code": 0,
  "output": [],
  "truncated": false,
  "next_offset": 0,
  "log_path": "/var/log/open-terminal/processes/20260728-093521-7b41ff.jsonl"
}

Note status: "done" and exit_code: 0, but "output": [].

# 2. Slow command -- also empty (rules out a client-side timing race)
curl -s -X POST "http://localhost:8000/execute?wait=5" \
  -H "Authorization: Bearer $OPEN_TERMINAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"command":"echo START; sleep 1; echo END"}'
### -> output: []

# 3. Interactive process -- input works, but its output is never captured
curl -s -X POST "http://localhost:8000/execute?wait=1" \
  -H "Authorization: Bearer $OPEN_TERMINAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"command":"bash"}'
### -> status: "running", note the returned id

curl -s -X POST "http://localhost:8000/execute/<ID>/input" \
  -H "Authorization: Bearer $OPEN_TERMINAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"input":"echo INSIDE\n"}'
### -> {"status":"ok"}

curl -s "http://localhost:8000/execute/<ID>/status" \
  -H "Authorization: Bearer $OPEN_TERMINAL_API_KEY"
### -> output: []  (should contain "INSIDE")

Root cause analysis

open_terminal/utils/runner.py, class PtyRunner, method read_output (lines 87-95):

async def read_output(self, log_file) -> None:
    loop = asyncio.get_event_loop()
    while True:
        try:
            data = await loop.run_in_executor(None, os.read, self._master_fd, 4096)
            if not data:
                break
        except OSError:
            break  # EIO when child exits
        ...
  1. Output is read from the PTY master FD in a loop (via run_in_executor).

  2. When the child process terminates, the master FD raises OSError (EIO) on the next read. This is normal PTY behaviour on Linux.

  3. The current code catches the OSError and immediately breaks.

  4. The bug: it breaks without draining the data still sitting in the kernel's PTY buffer. If the child wrote output and exited before the reader got scheduled (which is almost always the case for fast commands like echo hello), that output is discarded.

Additionally, in main.py (line 1187, in execute), the logging task is created after Popen has already started the process:

background_process.log_task = asyncio.create_task(log_process(background_process))

asyncio.create_task only schedules the coroutine; it does not run it until the event loop regains control. A very fast process can write its output and terminate within this window, making the lost-buffer scenario above even more likely.

Proposed fix

1. runner.py -- drain the PTY buffer before breaking (main fix):

async def read_output(self, log_file) -> None:
    loop = asyncio.get_event_loop()
    while True:
        try:
            data = await loop.run_in_executor(None, os.read, self._master_fd, 4096)
            if not data:
                break
        except OSError:
            # EIO when the child exits: drain any data still buffered
            # in the PTY master before giving up, so pending output
            # from fast-terminating commands is not lost.
            try:
                while True:
                    chunk = os.read(self._master_fd, 4096)
                    if not chunk:
                        break
                    if log_file:
                        await log_file.write(
                            json.dumps(
                                {
                                    "type": "output",
                                    "data": chunk.decode(errors="replace"),
                                    "ts": time.time(),
                                }
                            )
                            + "\n"
                        )
            except OSError:
                pass
            break

        if log_file:
            await log_file.write(
                json.dumps(
                    {
                        "type": "output",
                        "data": data.decode(errors="replace"),
                        "ts": time.time(),
                    }
                )
                + "\n"
            )

2. main.py -- optional hardening (line 1187): yield control right after creating the task, so log_process can reach its first read before a fast process exits:

background_process.log_task = asyncio.create_task(log_process(background_process))
await asyncio.sleep(0)  # let log_process start reading immediately

With the drain in (1) this second change is not strictly required, but it narrows the race window further.

Workaround (for users hitting this)

Until a fix lands, redirect command output to a file and read it back via the files API:

{ your-command; } > /tmp/output.txt 2>&1

then GET /files/read?path=/tmp/output.txt.

Workaround (for AI-agent setups)

In practice, open-terminal is usually consumed by an AI agent, so the effective
workaround does not require any code change on the server: instruct the model in
its system prompt to stop relying on the returned output field. For example:

"When running commands, always redirect stdout and stderr to a temporary file
(e.g. command > /tmp/result.txt 2>&1) and then read the result back with the
file-read tool. Never assume an empty output array means the command
produced no output."

With this single instruction added to the agent's system prompt (in my case, via
Open WebUI), the workflow remains fully functional — I have been working this
way daily with no lost output. Note, however, that this only helps LLM
consumers that follow system-prompt instructions; anything calling the API
directly still receives an empty output array.

I verified this analysis against the installed 0.11.34 source. Thanks for your work on this project!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions