Skip to content

Commit f4277c3

Browse files
tjbckkb5000
andcommitted
fix: bounded process log rotation and timestamp-sortable IDs (#52, #54)
Co-Authored-By: wjss <a123edcrfv@live.cn>
1 parent fd5c5de commit f4277c3

5 files changed

Lines changed: 50 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.11.10] - 2026-03-11
8+
9+
### Fixed
10+
11+
- 🧟 **Zombie process cleanup** — process runner `kill()` methods now use `os.killpg()` to signal the entire process group instead of just the direct child PID. Background processes started inside terminals or `/execute` sessions (e.g. `sleep 100 &`) are now properly terminated on cleanup. `_cleanup_session()` always calls `process.wait()` after force-killing to prevent zombie entries in the process table.
12+
- 🐳 **Docker PID 1 reaping** — added `tini` as the container's init process (`ENTRYPOINT ["/usr/bin/tini", "--", ...]`). Python/uvicorn no longer runs as PID 1, so orphaned grandchild processes are automatically reaped instead of accumulating as zombies.
13+
714
## [0.11.9] - 2026-03-11
815

916
### Added

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
2323
zip unzip tar gzip bzip2 xz-utils zstd p7zip-full \
2424
# System
2525
procps htop lsof strace sysstat \
26-
sudo tmux screen \
26+
sudo tmux screen tini \
2727
ca-certificates gnupg apt-transport-https \
2828
# Capabilities (needed for setcap on Python binary)
2929
libcap2-bin \
@@ -67,5 +67,5 @@ EXPOSE 8000
6767

6868
COPY entrypoint.sh /app/entrypoint.sh
6969

70-
ENTRYPOINT ["/app/entrypoint.sh"]
70+
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
7171
CMD ["run"]

open_terminal/main.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,7 +1246,13 @@ async def port_proxy(port: int, path: str, request: Request):
12461246

12471247

12481248
def _cleanup_session(session_id: str):
1249-
"""Clean up a terminal session's resources."""
1249+
"""Clean up a terminal session's resources.
1250+
1251+
For PTY sessions the shell is spawned with ``start_new_session=True``,
1252+
giving it a dedicated process group. We signal the *entire* group so
1253+
that background jobs started inside the terminal (e.g. ``sleep 999 &``)
1254+
are also reaped, and always call ``process.wait()`` to avoid zombies.
1255+
"""
12501256
session = _terminal_sessions.pop(session_id, None)
12511257
if session is None:
12521258
return
@@ -1258,13 +1264,23 @@ def _cleanup_session(session_id: str):
12581264
os.close(session["master_fd"])
12591265
except OSError:
12601266
pass
1267+
12611268
process = session["process"]
12621269
if process.poll() is None:
1263-
process.terminate()
1270+
# Signal the whole process group first (graceful).
12641271
try:
1265-
process.wait(timeout=2)
1272+
os.killpg(process.pid, signal.SIGTERM)
1273+
except (ProcessLookupError, PermissionError):
1274+
pass
1275+
try:
1276+
process.wait(timeout=3)
12661277
except subprocess.TimeoutExpired:
1267-
process.kill()
1278+
# Forceful kill of the entire group.
1279+
try:
1280+
os.killpg(process.pid, signal.SIGKILL)
1281+
except (ProcessLookupError, PermissionError):
1282+
pass
1283+
process.wait()
12681284

12691285
elif backend == "winpty":
12701286
pty_proc = session["pty_process"]

open_terminal/utils/runner.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,21 @@ async def read_output(self, log_file) -> None:
109109
def write_input(self, data: bytes) -> None:
110110
os.write(self._master_fd, data)
111111

112+
def _signal_group(self, sig: int) -> None:
113+
"""Send *sig* to the child's entire process group.
114+
115+
Falls back to signalling just the leader if the group is already gone.
116+
"""
117+
try:
118+
os.killpg(self._process.pid, sig)
119+
except (ProcessLookupError, PermissionError):
120+
try:
121+
self._process.send_signal(sig)
122+
except ProcessLookupError:
123+
pass
124+
112125
def kill(self, force: bool = False) -> None:
113-
if force:
114-
self._process.kill()
115-
else:
116-
self._process.send_signal(signal.SIGTERM)
126+
self._signal_group(signal.SIGKILL if force else signal.SIGTERM)
117127

118128
async def wait(self) -> int:
119129
return await asyncio.to_thread(self._process.wait)
@@ -176,10 +186,12 @@ async def drain_input(self) -> None:
176186
await self._process.stdin.drain()
177187

178188
def kill(self, force: bool = False) -> None:
179-
if force:
180-
self._process.kill()
181-
else:
182-
self._process.send_signal(signal.SIGTERM)
189+
sig = signal.SIGKILL if force else signal.SIGTERM
190+
try:
191+
os.killpg(self._process.pid, sig)
192+
except (ProcessLookupError, PermissionError, OSError):
193+
# No dedicated process group — fall back to the child directly.
194+
self._process.send_signal(sig)
183195

184196
async def wait(self) -> int:
185197
await self._process.wait()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "open-terminal"
3-
version = "0.11.9"
3+
version = "0.11.10"
44
description = "A remote terminal API."
55
readme = "README.md"
66
authors = [

0 commit comments

Comments
 (0)