Files
drouter/_dev/t-pty.py
T
liuzhuohua 2859705cee fix(webshell): 修复终端满员自锁 + 去掉滚动条并加清屏按钮
一、修 PTY 不可用(Web 终端报「创建终端失败:out of pty devices」)
   根因是目标机上 /dev/ptmx 这个符号链接丢了(devpts 里 /dev/pts/ptmx
   还在,但入口没了),于是 pty.openpty() 抛出误导性的「out of pty devices」。
   - 新增 ensure_ptmx():探测 -> ln -sf pts/ptmx /dev/ptmx -> mknod 兜底
   - 新增 _open_pty():首次失败先自愈再重试一次,仍失败就把真实原因
     (_pty_diagnose() 的结论)包进异常,不再只留一句天书
   - 服务启动时也自愈一次,开机即修,不等用户踩坑
   - 前端识别 PTY_FAIL 并给出可照抄的修复命令

二、修会话满员时的死锁(连到第 9 个会话整个终端服务冻死)
   connect 需要「看是否满员 -> 满了就回收 -> 再看一次」,这个序列必然
   在持锁时调到内部也要拿锁的 _reap_idle()。而 _lock 是普通 Lock,
   同线程二次 acquire 永久阻塞 —— 不报错、不超时,直接锁死;而且锁
   一直不放,其他会话的 read/write 也一起堵住,整个 Web 终端报废。
   - _lock 改为 RLock
   - connect 不再把 _reap_idle() 放在 with _lock 里面
   - _reap_idle() 锁内只做登记摘除,close() 移到锁外:
     close() 要等子进程退出(SIGHUP 后最多轮询 3 秒),持锁做同样会
     把并发请求堵死

三、终端外观(按需求)
   - 去掉右侧滚动条(scrollbar-width:none + ::-webkit-scrollbar),
     但保留滚动能力 —— 直接关掉 overflow 会让人再也翻不了历史
   - 高度改为 clamp(260px,54vh,560px) 随视口自适应;内容多了自动跟随
     最新输出。刻意不做「高度随内容增长」:wsMeasure() 用 box.h 算行数,
     内容撑高容器会反过来改变 PTY 行数,形成反馈环
   - 标题栏加「清屏」小按钮;滚上去离开底部时另出现「↓ 最新」——
     滚动条没了,总得留个回到底部的入口
   - 跟随阈值收敛成唯一的 WS_TAIL_GAP:wsAutoScroll 与 wsSyncTailBtn
     各写一份会出现「按钮没出现、输出也不跟着滚了」的死角

四、顺带清理
   - 删掉 app.css 里整套失效的旧终端样式(.tdot/.tt/.term-in/.tk-*,
     app.js 中 0 引用),它们已被新结构完全覆盖,留着只会让人改错地方
   - 合并 .term-wrap / .term-body 各自重复的第二条声明

测试:
   _dev/t-pty.py 扩到 37 项,含「不得自锁」的结构检查 + _reap_idle
   行为验证(摘除/关闭/返回条数);_dev/t-webshell.py 加 16 项外观契约。
   两者都挂进 devtools/sync.sh 预检链。
   新增 devtools/preview-webshell.py:用真实 app.css + 从 viewWebShell()
   抠出的 DOM 生成离线预览页,不手抄样式以免预览与线上不一致。

真机验收(192.168.7.3):
   8 个会话满员后第 9 个 0.01s 干净返回 BUSY(修复前是永久卡死);
   8 个会话 0.87s 全部断开、残留 0(修复前残留 8 个且后续全卡);
   /dev/pts 与 pty/nr 回落到 0,无 fd 泄漏;root shell 正常执行命令。
2026-10-01 01:29:09 +08:00

308 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web 终端 PTY 可用性回归测试。
背景(真实故障):
用户点 Web 终端,任何账号都报「创建终端失败:out of pty devices」。
sysctl kernel.pty.nr 显示 0 —— 一个 PTY 都没用,却说"用完了"。
根因是 /dev/ptmx 这个入口符号链接丢失(容器/虚拟化环境里 /dev 被
重新挂载时会丢),而 /dev/pts/ptmx 本身是好的。
这里钉住三件事:
1. shelld 启动时会自愈 /dev/ptmx(ensure_ptmx 幂等)
2. _open_pty 首次失败会先修入口再重试,不是直接抛错
3. 失败时的诊断信息包含「/dev/ptmx」这种能定位问题的关键词,
而不是只有一句 errno 天书
纯逻辑测试:把源码里的函数抽出来在隔离命名空间跑,用替身模拟
「ptmx 缺失 / 可用」两种情况,不碰真实 /dev。
"""
import ast
import os
import sys
SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"backend", "drouter-shelld.py")
_ck = [0, 0]
def ck(name, cond, extra=''):
_ck[1] += 1
if cond:
_ck[0] += 1
print(" [OK] %s" % name)
else:
print(" [!!] %s %s" % (name, extra))
def load_src():
with open(SRC, encoding='utf-8') as f:
return f.read()
def extract_funcs(src, names):
"""从模块源码里抽出指定函数,编译进一个干净命名空间。
只抽 FunctionDef —— 不抽模块级赋值。模块级的东西(SOCK_PATH、
_SESSIONS、_lock…)在替身环境里没有意义,而且抽进来会覆盖我们
注入的测试替身,导致读到真实路径。
"""
tree = ast.parse(src)
ns = {}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in names:
mod = ast.Module(body=[node], type_ignores=[])
exec(compile(mod, SRC, 'exec'), ns)
return ns
def main():
src = load_src()
print("一、源码必须定义了自愈与诊断函数")
for fn in ('ensure_ptmx', '_pty_diagnose', '_open_pty'):
ck("定义了 %s()" % fn, ('def %s(' % fn) in src)
# Session 必须经由 _open_pty 申请 PTY,而不是直接 pty.openpty() ——
# 直连的话就没有自愈和诊断,故障会原样抛给用户
sess_src = src.split('class Session')[1].split('\ndef ')[0] if 'class Session' in src else ''
ck("Session 不再直接调 pty.openpty()", 'pty.openpty()' not in sess_src)
ck("Session 经由 _open_pty() 申请", '_open_pty()' in sess_src)
print("\n二、ensure_ptmx 的三种分支")
ns = extract_funcs(src, ['ensure_ptmx'])
ensure_ptmx = ns['ensure_ptmx']
# 分支 A:入口已存在 → 直接返回 ok,不做任何动作
calls = []
class FakeOs:
path = type('p', (), {'exists': staticmethod(lambda p: True)})()
@staticmethod
def symlink(a, b):
calls.append(('symlink', a, b))
@staticmethod
def mknod(*a):
calls.append(('mknod', a))
real_os = ns['ensure_ptmx'].__globals__.get('os')
ns['ensure_ptmx'].__globals__['os'] = FakeOs
ok, detail = ensure_ptmx()
ck("入口存在时返回 (True,'ok')", (ok, detail) == (True, 'ok'), str((ok, detail)))
ck("入口存在时不创建任何东西", calls == [], str(calls))
# 分支 B:入口缺失但 /dev/pts/ptmx 在 → 补符号链接
calls = []
class FakeOs2:
path = type('p', (), {'exists': staticmethod(
lambda p: p == '/dev/pts/ptmx')})()
@staticmethod
def makedev(a, b):
return 0
@staticmethod
def symlink(a, b):
calls.append((a, b))
return None
@staticmethod
def mknod(*a):
calls.append(('mknod', a))
ns['ensure_ptmx'].__globals__['os'] = FakeOs2
ok, detail = ensure_ptmx()
ck("缺失时返回 True", ok is True, str((ok, detail)))
ck("缺失时建立 pts/ptmx 符号链接",
calls == [('pts/ptmx', '/dev/ptmx')], str(calls))
# 分支 C:什么都建不了 → 返回 False + 原因(不抛异常)
class FakeOs3:
path = type('p', (), {'exists': staticmethod(lambda p: False)})()
@staticmethod
def makedev(a, b):
return 0
@staticmethod
def symlink(a, b):
raise OSError(30, 'Read-only file system')
@staticmethod
def mknod(*a):
raise OSError(30, 'Read-only file system')
ns['ensure_ptmx'].__globals__['os'] = FakeOs3
ok, detail = ensure_ptmx()
ck("无力修复时返回 False", ok is False, str((ok, detail)))
ck("返回的 detail 非空(便于日志)", bool(detail), repr(detail))
print("\n三、_open_pty 失败时必须先自愈再重试")
nsf = extract_funcs(src, ['_open_pty'])
_open_pty = nsf['_open_pty']
g = _open_pty.__globals__
seq = {'n': 0}
healed = {'v': False}
class FakePty:
@staticmethod
def openpty():
seq['n'] += 1
if seq['n'] == 1:
raise OSError(5, 'out of pty devices')
return (7, 8) # 第二次成功
def fake_ensure():
healed['v'] = True
return True, 'symlink created'
g['pty'] = FakePty
g['ensure_ptmx'] = fake_ensure
g['log'] = lambda *a, **k: None
g['_pty_diagnose'] = lambda: '替身'
r = _open_pty()
ck("首败后自愈并重试成功", r == (7, 8), str(r))
ck("重试前确实调用了 self-heal", healed['v'] is True)
ck("openpty 被调用了两次", seq['n'] == 2, str(seq['n']))
print("\n四、彻底失败时的诊断信息要能定位问题")
nsf2 = extract_funcs(src, ['_open_pty'])
_open_pty2 = nsf2['_open_pty']
g2 = _open_pty2.__globals__
class AlwaysFailPty:
@staticmethod
def openpty():
raise OSError(5, 'out of pty devices')
g2['pty'] = AlwaysFailPty
g2['ensure_ptmx'] = lambda: (False, 'Read-only file system')
g2['log'] = lambda *a, **k: None
# 诊断函数:模拟 /dev/ptmx 缺失的情形
g2['_pty_diagnose'] = lambda: '/dev/ptmx 缺失'
try:
_open_pty2()
ck("无救时抛异常", False, '竟然没抛')
except OSError as e:
msg = str(e)
ck("异常里保留了原始 errno 文案", 'out of pty devices' in msg, msg)
ck("异常里带上了可定位的诊断", '/dev/ptmx' in msg, msg)
print("\n五、诊断函数覆盖关键检查点")
nsd = extract_funcs(src, ['_pty_diagnose'])
dsrc = src.split('def _pty_diagnose')[1].split('\ndef ')[0]
for kw in ('/dev/ptmx', '/dev/pts', 'pty/nr', 'pty/max'):
ck("诊断检查了 %s" % kw, kw in dsrc)
print("\n六、启动路径也会自愈(不是只在连接时)")
main_src = src.split('def main()')[1] if 'def main()' in src else ''
ck("main() 里调用了 ensure_ptmx", 'ensure_ptmx()' in main_src)
ck("main() 里对失败情况写日志", 'PTY_UNAVAILABLE' in main_src)
ck("自愈成功也写日志(便于追查)", 'PTY_HEAL' in main_src)
print("\n七、前端给了可照抄的修复命令")
app = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'web', 'app.js')
ajs = open(app, encoding='utf-8').read()
ck("前端识别 PTY_FAIL", "'PTY_FAIL'" in ajs or 'PTY_FAIL' in ajs)
ck("前端提示 out of pty devices 的含义", 'out of pty devices' in ajs)
ck("前端给出修复命令", 'ln -sf pts/ptmx /dev/ptmx' in ajs)
ck("前端给出重启命令", 'systemctl restart drouter-shelld' in ajs)
print("\n八、会话回收不得自锁(满员时整个终端服务会冻死)")
# 背景:connect 需要「看是否满员 → 满了就回收 → 再看一次」这个序列,
# 天然而必然地会在持锁时调到内部也要拿锁的 _reap_idle()。
# 用普通 Lock 会把自己永久锁死(同线程二次 acquire),表现为
# 「连到第 9 个会话时彻底卡住」——而且因为锁一直不放,
# 其他会话的 read/write 也一起堵住,整个 Web 终端报废。
ck("_lock 用 RLock(可重入)", '_lock = threading.RLock()' in src)
# 只查模块级 _lock 的赋值。不能写成 `'_lock = threading.Lock()' not in src` ——
# Session.__init__ 里的 `self.buf_lock = threading.Lock()` 尾部正好命中
# 这个子串,会误报。那个是每会话独立的输出缓冲锁,非重入是对的。
ck("模块级 _lock 不是普通 Lock",
not __import__('re').search(r'(?m)^_lock\s*=\s*threading\.Lock\(\)', src))
def blocked_span(lines, i):
"""返回从 lines[i](一个 `xxx:` 块头)开始、该缩进块结束的下标。"""
ind = len(lines[i]) - len(lines[i].lstrip())
j = i + 1
while j < len(lines):
s = lines[j]
if not s.strip():
j += 1
continue
if (len(s) - len(s.lstrip())) <= ind:
break
j += 1
return j
def calls_inside_lock(text, needle):
"""text 里是否存在「在被 with _lock: 包住的缩进块内」调用 needle。"""
lines = text.splitlines()
for i, l in enumerate(lines):
if l.strip() == 'with _lock:':
end = blocked_span(lines, i)
if needle in "\n".join(lines[i:end]):
return True
return False
reap_src = src.split('def _reap_idle')[1].split('\ndef ')[0]
ck("_reap_idle 不同时持锁调 close()(close 最长等子进程 3 秒)",
not calls_inside_lock(reap_src, '.close()'))
conn_src = src.split("if op == 'connect':")[1].split("if op == 'read':")[0]
ck("connect 不持锁调 _reap_idle()", not calls_inside_lock(conn_src, '_reap_idle()'))
ck("connect 仍会在满员时尝试回收", '_reap_idle()' in conn_src,
"回收被删掉就退化成「每次都直接拒绝」")
# 行为验证:真的丢一个闲置会话进去,看它是否被摘除并 close
print("\n九、_reap_idle() 行为正确(摘除 + 关闭 + 返回条数)")
ns = {'threading': __import__('threading'), 'time': __import__('time'),
'IDLE_TIMEOUT': 60, 'MAX_SESSIONS': 8, 'log': lambda *a, **k: None}
live = {}
ns['_SESSIONS'] = live
ns['_lock'] = __import__('threading').RLock()
# 注意 reap_src 是 "def _reap_idle" 之后的部分,拼回去才能编译
exec(compile('def _reap_idle' + reap_src, '<reap>', 'exec'), ns)
class _P(object):
def poll(self):
return None
closed = []
class _S(object):
alive = True
def __init__(self, last):
self.last = last
self.proc = _P()
def close(self):
closed.append(self)
now = ns['time'].time()
live['old'] = _S(now - 999) # 闲置超时 → 该被回收
live['new'] = _S(now) # 刚活动过 → 该留下
n = ns['_reap_idle']()
ck("闲置会话已从 _SESSIONS 摘除", 'old' not in live, str(list(live)))
ck("活跃会话被保留", 'new' in live, str(list(live)))
ck("闲置会话被 close()", len(closed) == 1, str(len(closed)))
ck("返回回收条数", n == 1, str(n))
ck("空表调用返回 0 且不抛", ns['_reap_idle']() == 0)
print("\n" + "=" * 56)
print("结果:%d 项,失败 %d 项" % (_ck[1], _ck[1] - _ck[0]))
return 1 if _ck[0] != _ck[1] else 0
if __name__ == '__main__':
sys.exit(main())