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
...
-
Output is read from the PTY master FD in a loop (via run_in_executor).
-
When the child process terminates, the master FD raises OSError (EIO) on the next read. This is normal PTY behaviour on Linux.
-
The current code catches the OSError and immediately breaks.
-
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!
Summary
On Linux (Unix PTY backend),
POST /executereturns"output": []for commands that produce output, even though the response reportsstatus: "done"and the correctexit_code. This happens because the PTY reader inPtyRunner.read_outputbreaks out of its loop onOSError(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
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"andexit_code: 0, but"output": [].Root cause analysis
open_terminal/utils/runner.py, classPtyRunner, methodread_output(lines 87-95):Output is read from the PTY master FD in a loop (via
run_in_executor).When the child process terminates, the master FD raises
OSError(EIO) on the next read. This is normal PTY behaviour on Linux.The current code catches the
OSErrorand immediatelybreaks.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, inexecute), the logging task is created afterPopenhas already started the process:asyncio.create_taskonly 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):2.
main.py-- optional hardening (line 1187): yield control right after creating the task, solog_processcan reach its first read before a fast process exits: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>&1then
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
outputfield. For example: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
outputarray.I verified this analysis against the installed 0.11.34 source. Thanks for your work on this project!