19 Commits

Author SHA1 Message Date
CakeCN
692b300f9b Merge branch 'main' of https://hsamooc.com/git/CakeCN/hsa 2026-01-08 20:32:29 +08:00
CakeCN
4ba25d7aa1 准备修复多租户 2026-01-08 20:32:28 +08:00
CakeCN
a8bccd7c3f 重构了基于websocket的SSH终端连接方案 2026-01-08 00:53:29 +08:00
CakeCN
2e1139d101 防止服务器宕机 : Socket operation on non-socket 错误不会导致服务器崩溃 2026-01-08 00:31:48 +08:00
CakeCN
eba1e8052c 创建了 create_terminal 方法;没有子进程时,自动调用 create_terminal 重新创建 2026-01-08 00:28:39 +08:00
CakeCN
6fba03ea4a 基于room的多租户WebSocket连接管理实现 2026-01-08 00:22:24 +08:00
CakeCN
09103abf38 移除了不存在的属性引用 :删除了导致错误的 eventlet.greenthread.main 相关代码 2026-01-08 00:11:31 +08:00
CakeCN
6ecd0c6735 包装了 eventlet.greenthread.main 函数,捕获所有绿色线程中的异常 2026-01-08 00:10:33 +08:00
CakeCN
a07eee1ffc fix 语音识别重复增加的问题 2026-01-08 00:05:23 +08:00
CakeCN
848b235ddc 移除了不存在的属性引用 :删除了导致错误的代码行 eventlet.debug.hub_prevent_multiple_readers(False) 2026-01-08 00:02:50 +08:00
CakeCN
7f469803df 在捕获到致命错误时,调用 self.close() 彻底关闭连接 2026-01-08 00:01:26 +08:00
CakeCN
4dd2667785 fix module 'eventlet.hubs' has no attribute 'handle_error' 2026-01-07 23:57:25 +08:00
CakeCN
f8e0b461ba fix module 'eventlet.hubs.hub' has no attribute 'Hub' 错误 2026-01-07 23:55:16 +08:00
CakeCN
607c6dfbab 有 eventlet.hubs 相关的错误都会被捕获并打印而不会向外传播导致服务器崩溃 2026-01-07 23:52:01 +08:00
CakeCN
73275cffad 每次 IO 操作之前或期间都会检查文件可用性,并在文件不可用时优雅处理 2026-01-07 20:46:32 +08:00
CakeCN
ee20e8b85b replacing the dynamic selectedItem.path+'/'+selectedItem.name with the filePath 2026-01-07 19:13:38 +08:00
CakeCN
b806da10c4 对 terminal_service.py 文件的全面检查和修复:在子进程中使用 os._exit(1) 替代 disconnect() ,避免在子进程中调用 SocketIO 方法导致的错误;修复了 SSH 硬编码端口问题 等 2026-01-07 18:57:00 +08:00
CakeCN
f818d84208 添加了对 errno 88 (Socket operation on non-socket)的处理 2026-01-07 18:52:17 +08:00
CakeCN
9231c14013 从run 中移除了全局try-except块,增强了eventlet内部错误处理 2026-01-07 01:33:25 +08:00
5 changed files with 642 additions and 154 deletions

View File

@@ -90,7 +90,7 @@ document.addEventListener('DOMContentLoaded', function () {
socket.on('voiceMessage', function (data) { socket.on('voiceMessage', function (data) {
console.log(data); console.log(data);
const input = document.getElementById('messageInput'); const input = document.getElementById('messageInput');
input.value += data; input.value = data;
}); });
socket.on('message', function (data) { socket.on('message', function (data) {
// 显示服务器的回复消息 - 单个消息 // 显示服务器的回复消息 - 单个消息

View File

@@ -1,6 +1,9 @@
import eventlet import eventlet
from eventlet import greenio
import logging import logging
import traceback
# 获取当前 socketio 实例并配置错误处理
import sys
# 配置 eventlet 日志,抑制 IOClosed 等错误日志 # 配置 eventlet 日志,抑制 IOClosed 等错误日志
logging.getLogger('eventlet.hubs').setLevel(logging.CRITICAL) logging.getLogger('eventlet.hubs').setLevel(logging.CRITICAL)
logging.getLogger('eventlet.greenio').setLevel(logging.CRITICAL) logging.getLogger('eventlet.greenio').setLevel(logging.CRITICAL)
@@ -8,24 +11,193 @@ logging.getLogger('eventlet.greenio').setLevel(logging.CRITICAL)
# 进行猴子补丁操作 # 进行猴子补丁操作
eventlet.monkey_patch() eventlet.monkey_patch()
# 配置 eventlet hub 以更优雅地处理 IOClosed 错误 # 配置 eventlet 以更优雅地处理错误,避免疯狂报错
try: try:
from eventlet.hubs import hub # 1. 首先,禁用 eventlet 的调试日志,减少输出
# 保存原始的 handle_error 方法 logging.getLogger('eventlet').setLevel(logging.CRITICAL)
original_handle_error = hub.Hub.handle_error logging.getLogger('socketio').setLevel(logging.CRITICAL)
logging.getLogger('werkzeug').setLevel(logging.CRITICAL)
def custom_handle_error(self, context, type, value, tb): # 2. 配置 eventlet 相关设置移除了不存在的debug属性
# 忽略 IOClosed 错误
if type.__name__ == 'IOClosed' and 'Operation on closed file' in str(value): # 3. 避免重复打印相同错误的机制
seen_errors = set()
max_error_logs = 5 # 每个错误最多打印5次
error_counts = {}
def is_duplicate_error(e):
"""检查是否为重复错误"""
error_key = f"{type(e).__name__}: {e}"
if error_key in seen_errors:
error_counts[error_key] = error_counts.get(error_key, 0) + 1
if error_counts[error_key] > max_error_logs:
return True
else:
seen_errors.add(error_key)
error_counts[error_key] = 1
return False
# 4. 只修改关键的 greenio 方法,确保在致命错误时彻底关闭连接
# 增强 socketio 错误处理,确保不会因为单个连接错误而宕机
try:
original_excepthook = sys.excepthook
def custom_excepthook(exc_type, exc_value, exc_traceback):
"""全局异常钩子,捕获所有未处理的异常"""
error_str = str(exc_value)
if exc_type.__name__ in ['OSError', 'IOError'] and any(msg in error_str for msg in ['Bad file descriptor', 'Socket operation on non-socket', 'Operation on closed file']):
# 处理各种socket错误不导致服务器宕机
if not is_duplicate_error(exc_value):
print(f"[Global Error Handler] {exc_type.__name__}: {exc_value}")
print("[Traceback]")
traceback.print_exception(exc_type, exc_value, exc_traceback)
# 不调用原始的 excepthook防止服务器宕机
return return
# 其他错误调用原始方法 # 其他异常调用原始的 excepthook
original_handle_error(self, context, type, value, tb) original_excepthook(exc_type, exc_value, exc_traceback)
# 替换原始方法 # 设置全局异常钩子
hub.Hub.handle_error = custom_handle_error sys.excepthook = custom_excepthook
except Exception as e: except Exception as e:
# 如果修改失败,忽略错误 # 如果修改失败,忽略错误
print(f"[Global Excepthook Patch Error] {e}")
# 保存并替换原始的 greenio 方法
if hasattr(greenio.base.GreenSocket, '_recv_loop'):
original_recv_loop = greenio.base.GreenSocket._recv_loop
def custom_recv_loop(self, recv_func, recv_args, *args, **kwargs):
try:
return original_recv_loop(self, recv_func, recv_args, *args, **kwargs)
except Exception as e:
if not is_duplicate_error(e):
print(f"[Eventlet IO Error] _recv_loop: {type(e).__name__}: {e}")
print("[Traceback]")
traceback.print_exc()
# 彻底关闭连接,不再继续处理
try:
self.close()
except Exception:
pass pass
return b''
greenio.base.GreenSocket._recv_loop = custom_recv_loop
if hasattr(greenio.base.GreenSocket, 'recv_into'):
original_recv_into = greenio.base.GreenSocket.recv_into
def custom_recv_into(self, buffer, nbytes=0, flags=0):
try:
return original_recv_into(self, buffer, nbytes, flags)
except Exception as e:
if not is_duplicate_error(e):
print(f"[Eventlet IO Error] recv_into: {type(e).__name__}: {e}")
print("[Traceback]")
traceback.print_exc()
# 彻底关闭连接,不再继续处理
try:
self.close()
except Exception:
pass
return 0
greenio.base.GreenSocket.recv_into = custom_recv_into
if hasattr(greenio.base.GreenSocket, '_send_loop'):
original_send_loop = greenio.base.GreenSocket._send_loop
def custom_send_loop(self, send_func, data, *args, **kwargs):
try:
return original_send_loop(self, send_func, data, *args, **kwargs)
except Exception as e:
if not is_duplicate_error(e):
print(f"[Eventlet IO Error] _send_loop: {type(e).__name__}: {e}")
print("[Traceback]")
traceback.print_exc()
# 彻底关闭连接,不再继续处理
try:
self.close()
except Exception:
pass
return 0
greenio.base.GreenSocket._send_loop = custom_send_loop
if hasattr(greenio.base.GreenSocket, 'send'):
original_send = greenio.base.GreenSocket.send
def custom_send(self, data, flags=0):
try:
return original_send(self, data, flags)
except Exception as e:
if not is_duplicate_error(e):
print(f"[Eventlet IO Error] send: {type(e).__name__}: {e}")
print("[Traceback]")
traceback.print_exc()
# 彻底关闭连接,不再继续处理
try:
self.close()
except Exception:
pass
return 0
greenio.base.GreenSocket.send = custom_send
# 5. 处理 WSGI 中的错误,确保彻底清理
try:
import eventlet.wsgi
if hasattr(eventlet.wsgi.HttpProtocol, 'handle_one_request'):
original_wsgi_handle_one_request = eventlet.wsgi.HttpProtocol.handle_one_request
def custom_wsgi_handle_one_request(self):
try:
return original_wsgi_handle_one_request(self)
except Exception as e:
if not is_duplicate_error(e):
print(f"[Eventlet WSGI Request Error] {type(e).__name__}: {e}")
print("[Traceback]")
traceback.print_exc()
# 彻底清理连接,不再继续
try:
self.rfile.close()
self.wfile.close()
self.finish()
except Exception:
pass
return
eventlet.wsgi.HttpProtocol.handle_one_request = custom_wsgi_handle_one_request
if hasattr(eventlet.wsgi.HttpProtocol, 'handle_one_response'):
original_wsgi_handle_one_response = eventlet.wsgi.HttpProtocol.handle_one_response
def custom_wsgi_handle_one_response(self):
try:
return original_wsgi_handle_one_response(self)
except Exception as e:
if not is_duplicate_error(e):
print(f"[Eventlet WSGI Response Error] {type(e).__name__}: {e}")
print("[Traceback]")
traceback.print_exc()
# 彻底清理连接,不再继续
try:
self.rfile.close()
self.wfile.close()
self.finish()
except Exception:
pass
return
eventlet.wsgi.HttpProtocol.handle_one_response = custom_wsgi_handle_one_response
except Exception as e:
# 如果修改失败,忽略错误
print(f"[WSGI Patch Error] {e}")
print("[Eventlet Error Handling] Configured to handle errors gracefully, avoiding duplicate logs")
except Exception as e:
# 如果修改失败,打印错误但继续运行
print(f"[Eventlet Patch Initialization Error] {e}")
traceback.print_exc()
from flask import Flask from flask import Flask
from .views.routes import main_bp from .views.routes import main_bp

View File

@@ -14,6 +14,7 @@ import termios
import struct import struct
import fcntl import fcntl
import psutil import psutil
TERM_INIT_CONFIG = { TERM_INIT_CONFIG = {
# instead of local server runnning this web terminal service # instead of local server runnning this web terminal service
# "domain" is the target that you want to access through local server (with this web terminal) # "domain" is the target that you want to access through local server (with this web terminal)
@@ -24,9 +25,108 @@ TERM_INIT_CONFIG = {
'ssh': '/usr/bin/ssh' 'ssh': '/usr/bin/ssh'
} }
} }
terminal_bp = Blueprint('terminal', __name__, url_prefix='/vsc-like') terminal_bp = Blueprint('terminal', __name__, url_prefix='/vsc-like')
class TerminalSessionManager:
"""终端会话管理器,用于统一管理所有终端会话"""
def __init__(self):
# 存储所有终端会话key为session_idvalue为会话配置
self.sessions = {}
# 线程锁,保证多线程安全
self.lock = threading.Lock()
# 最大终端数量限制
self.max_terminals = 100
# 终端空闲超时时间(秒)
self.idle_timeout = 3600 # 1小时
def create_session(self):
"""创建新的终端会话"""
with self.lock:
# 检查是否达到最大终端数量
if len(self.sessions) >= self.max_terminals:
return None, "Maximum number of terminals reached"
# 生成唯一的会话ID和房间ID
session_id = str(uuid.uuid4())
room_id = f"terminal_{session_id}"
# 创建会话配置
session_config = {
**TERM_INIT_CONFIG.copy(),
'session_id': session_id,
'room_id': room_id,
'created_at': time.time(),
'last_activity': time.time()
}
# 保存会话
self.sessions[session_id] = session_config
return session_config, None
def get_session(self, session_id):
"""获取会话配置"""
with self.lock:
return self.sessions.get(session_id)
def update_session(self, session_id, updates):
"""更新会话配置"""
with self.lock:
if session_id not in self.sessions:
return False
# 更新会话配置
self.sessions[session_id].update(updates)
# 更新最后活动时间
self.sessions[session_id]['last_activity'] = time.time()
return True
def delete_session(self, session_id):
"""删除会话"""
with self.lock:
if session_id in self.sessions:
del self.sessions[session_id]
return True
return False
def get_session_by_room_id(self, room_id):
"""通过房间ID获取会话"""
with self.lock:
for session_config in self.sessions.values():
if session_config.get('room_id') == room_id:
return session_config
return None
def cleanup_inactive_sessions(self):
"""清理不活动的会话"""
with self.lock:
current_time = time.time()
expired_sessions = []
# 找出超时的会话
for session_id, session_config in self.sessions.items():
if current_time - session_config['last_activity'] > self.idle_timeout:
expired_sessions.append(session_id)
# 删除超时的会话
for session_id in expired_sessions:
del self.sessions[session_id]
return expired_sessions
def get_active_session_count(self):
"""获取当前活动会话数量"""
with self.lock:
return len(self.sessions)
# 创建全局终端会话管理器实例
terminal_manager = TerminalSessionManager()
def set_winsize(fd, row, col, xpix=0, ypix=0): def set_winsize(fd, row, col, xpix=0, ypix=0):
try: try:
@@ -37,31 +137,41 @@ def set_winsize(fd, row, col, xpix=0, ypix=0):
pass pass
def read_and_forward_pty_output(fd=None, pid=None, room_id=None,namespace=None): def read_and_forward_pty_output(fd=None, pid=None, room_id=None, namespace=None):
""" """
read data on pty master from the pty slave, and emit to the web terminal visitor read data on pty master from the pty slave, and emit to the web terminal visitor
""" """
max_read_bytes = 1024 * 20 max_read_bytes = 1024 * 20
timeout=0.1 # 初始超时时间设置为较短值,确保响应迅速
timeout = 0.05
# 最大超时时间,避免太频繁的检查
max_timeout = 0.5
# 成功读取数据后的重置超时时间
reset_timeout = 0.05
try: try:
while True: while True:
# 等待一段时间减少CPU占用
socketio.sleep(timeout) socketio.sleep(timeout)
timeout=min(timeout*2, 0.4)
# using flask default web server, or uwsgi production web server # 使用指数退避算法调整超时时间,但不超过最大值
# when the child process is terminated, it will not disappear from linux process list timeout = min(timeout * 1.5, max_timeout)
# and keep staying as a zombie process until the parent exits.
# 检查子进程状态
try: try:
child_process = psutil.Process(pid) child_process = psutil.Process(pid)
except psutil.NoSuchProcess as err: except psutil.NoSuchProcess:
# Process already terminated, clean up any zombie # 进程已终止,清理僵尸进程
try: try:
os.waitpid(pid, os.WNOHANG) os.waitpid(pid, os.WNOHANG)
except Exception: except Exception:
pass pass
return return
# 检查进程状态,如果不是运行或睡眠状态,则退出
if child_process.status() not in ('running', 'sleeping'): if child_process.status() not in ('running', 'sleeping'):
# Process is terminated or in other state, clean up
try: try:
# 等待进程终止
child_process.wait(timeout=1) child_process.wait(timeout=1)
except Exception: except Exception:
try: try:
@@ -69,39 +179,43 @@ def read_and_forward_pty_output(fd=None, pid=None, room_id=None,namespace=None):
except Exception: except Exception:
pass pass
return return
# 如果文件描述符有效,尝试读取数据
if fd: if fd:
timeout_sec = 0
try: try:
# Check if file descriptor is still valid by trying to select on it # 使用非阻塞select检查是否有数据可读
(data_ready, _, _) = select.select([fd], [], [], timeout_sec) (data_ready, _, _) = select.select([fd], [], [], 0)
if data_ready: if data_ready:
timeout=0.1 # 有数据可读,重置超时时间
timeout = reset_timeout
try: try:
# 读取数据
output = os.read(fd, max_read_bytes).decode() output = os.read(fd, max_read_bytes).decode()
except (OSError, IOError, EOFError) as err: except (OSError, IOError, EOFError):
# File descriptor closed or other IO error, exit gracefully # 文件描述符已关闭或其他IO错误优雅退出
return return
except UnicodeDecodeError as err: except UnicodeDecodeError as err:
output = """ # 处理编码错误
output = f"""
***AQUI WEB TERM ERR*** ***AQUI WEB TERM ERR***
Unicode decode error: {} Unicode decode error: {err}
*********************** ***********************
""".format(err) """
# the key for different visitor to get different terminal (instead of mixing up)
# is to let the background task push pty response to each one's own (default) ROOM! # 发送数据到客户端
try: try:
namespace.emit("pty_output", {"output": output}, room=room_id) namespace.emit("pty_output", {"output": output}, room=room_id)
except Exception: except Exception:
# If emit fails, the client is probably disconnected, exit # 如果发送失败,客户端可能已断开连接,退出
return return
except (OSError, IOError) as err: except (OSError, IOError):
# File descriptor closed or invalid, exit gracefully # 文件描述符无效,优雅退出
return return
except Exception as e: except Exception as e:
# Catch any other unexpected exceptions to prevent server crash # 捕获任何其他未预期的异常,防止服务器崩溃
pass current_app.logger.error(f"Unexpected error in read_and_forward_pty_output: {e}")
finally: finally:
# Clean up file descriptor if it's open # 清理文件描述符
if fd: if fd:
try: try:
os.close(fd) os.close(fd)
@@ -109,117 +223,326 @@ def read_and_forward_pty_output(fd=None, pid=None, room_id=None,namespace=None):
pass pass
class VSCLikeNameSpace(Namespace): class VSCLikeNameSpace(Namespace):
def on_connect(self): def __init__(self, namespace=None):
"""new client connected""" super().__init__(namespace)
if session.get('terminal_config', {}).get('child_pid', None): # 存储客户端socket_id到session_id的映射
print(session['terminal_config']['child_pid']) self.socket_session_map = {}
# already started child process, don't start another # 线程锁,保证多线程安全
return self.lock = threading.Lock()
def create_terminal(self, session_id):
"""创建新的终端并更新会话配置"""
# 获取会话配置
terminal_config = terminal_manager.get_session(session_id)
if not terminal_config:
current_app.logger.error(f"Session not found: {session_id}")
return None
room_id = terminal_config['room_id']
current_app.logger.debug(f"Creating new terminal for room: {room_id}")
# create child process attached to a pty we can read from and write to # create child process attached to a pty we can read from and write to
(child_pid, fd) = pty.fork() (child_pid, fd) = pty.fork()
lesson_path = session.get('path')
if child_pid == 0: if child_pid == 0:
# this is the child process fork. # this is the child process fork.
# anything printed here will show up in the pty, including the output # anything printed here will show up in the pty, including the output
# of this subprocess # of this subprocess
# subprocess.run('bash') try:
term_type = session.get('terminal_config').get('term_type') # 获取终端配置
path = TERM_INIT_CONFIG.get('client_path', {}).get(term_type, None) term_type = terminal_config.get('term_type')
if not path:
print("Can't locate {} binary, exit".format(term_type)) if not term_type:
disconnect() print("Terminal type not specified, exit")
os._exit(1)
path = terminal_config.get('client_path', {}).get(term_type, None)
if not path or not os.path.exists(path):
print(f"Can't locate {term_type} binary at {path}, exit")
os._exit(1)
# 获取连接参数
username = terminal_config.get('username')
domain = terminal_config.get('domain')
port = terminal_config.get('port')
if not username or not domain:
print("Missing required connection parameters, exit")
os._exit(1)
if term_type == 'telnet': if term_type == 'telnet':
# switch to the right location of your telnet binary (example comes from OSX which got telnet from brew) # 使用telnet连接
# or you can also make work like auto-detection, or manually but configurable if not port:
os.execl(path, 'telnet', '-l', session['terminal_config']['username'], print("Port not specified for telnet, exit")
session['terminal_config']['domain'], '{}'.format(session['terminal_config']['port'])) os._exit(1)
os.execl(path, 'telnet', '-l', username, domain, str(port))
elif term_type == 'ssh': elif term_type == 'ssh':
os.execl(path,'ssh', '-p','22', # 使用ssh连接
#'{}'.format(session['terminal_config']['port']), ssh_port = port if port else 22 # 默认端口22
'{}@{}'.format(session['terminal_config']['username'], session['terminal_config']['domain'])) os.execl(path, 'ssh', '-p', str(ssh_port), f'{username}@{domain}')
else: else:
current_app.logger.debug("wrong term type {}".format(term_type)) print(f"Wrong term type {term_type}, exit")
disconnect() os._exit(1)
session['terminal_config'] = TERM_INIT_CONFIG except Exception as e:
print(f"Error in child process: {e}")
os._exit(1)
else: else:
session['terminal_config']['fd'] = fd # 更新会话配置,保存到终端管理器
session['terminal_config']['child_pid'] = child_pid updates = {
session['terminal_config']['room_id'] = rooms()[0] 'fd': fd,
session.modified = True 'child_pid': child_pid
}
terminal_manager.update_session(session_id, updates)
# 设置初始窗口大小
set_winsize(fd, 50, 50) set_winsize(fd, 50, 50)
current_app.logger.debug("child pid = {}".format(child_pid))
current_app.logger.debug("rooms of this session = {}".format(rooms())) # 记录日志
socketio.start_background_task(read_and_forward_pty_output, fd, child_pid, rooms()[0],self) current_app.logger.debug(f"Child process created: {child_pid} for room: {room_id}")
# 启动后台任务读取pty输出传入正确的room_id
socketio.start_background_task(read_and_forward_pty_output, fd, child_pid, room_id, self)
current_app.logger.debug("Background task running")
return terminal_config
def on_connect(self):
"""new client connected"""
# 创建新的终端会话
terminal_config, error = terminal_manager.create_session()
if not terminal_config:
current_app.logger.error(f"Failed to create terminal session: {error}")
return
session_id = terminal_config['session_id']
room_id = terminal_config['room_id']
# 加入房间
join_room(room_id)
# 保存socket_id到session_id的映射
socket_id = request.sid
with self.lock:
self.socket_session_map[socket_id] = session_id
current_app.logger.debug(f"Client connected, session_id: {session_id}, room_id: {room_id}, socket_id: {socket_id}")
# 创建终端
self.create_terminal(session_id)
current_app.logger.debug("background task running") current_app.logger.debug("background task running")
# print(session)
def on_pty_input(self, data): def on_pty_input(self, data):
"""write to the child pty, which now is the ssh process from this machine to the 'domain' configured """write to the child pty, which now is the ssh process from this machine to the 'domain' configured
""" """
print(f"get data {data}") # 获取socket_id对应的session_id
try: socket_id = request.sid
child_process = psutil.Process(session.get('terminal_config').get('child_pid')) with self.lock:
except psutil.NoSuchProcess as err: session_id = self.socket_session_map.get(socket_id)
disconnect()
session['terminal_config'] = TERM_INIT_CONFIG if not session_id:
current_app.logger.error(f"No session found for socket: {socket_id}")
return return
# 输入验证
if not isinstance(data, dict):
current_app.logger.error("Invalid input format: expected dictionary")
return
input_data = data.get("input")
if not isinstance(input_data, str):
current_app.logger.error("Invalid input data: expected string")
return
# 限制输入长度,防止缓冲区溢出
if len(input_data) > 1024:
input_data = input_data[:1024]
current_app.logger.warning("Input truncated due to length")
try:
terminal_config = terminal_manager.get_session(session_id)
if not terminal_config:
current_app.logger.error(f"Session not found: {session_id}")
return
child_pid = terminal_config.get('child_pid')
# 如果没有子进程,重新创建终端
if not child_pid:
current_app.logger.debug(f"No child process found for session: {session_id}, creating new terminal")
terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for session: {session_id}")
return
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error(f"Failed to get child_pid for session: {session_id}")
return
# 检查子进程是否存在且运行中
try:
child_process = psutil.Process(child_pid)
if child_process.status() not in ('running', 'sleeping'): if child_process.status() not in ('running', 'sleeping'):
disconnect() current_app.logger.debug(f"Child process not running for session: {session_id}, creating new terminal")
session['terminal_config'] = TERM_INIT_CONFIG terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for session: {session_id}")
return return
# print(session) child_pid = terminal_config.get('child_pid')
# print(data, 'from input') if not child_pid:
fd = session.get('terminal_config').get('fd') current_app.logger.error(f"Failed to get child_pid for session: {session_id}")
return
except psutil.NoSuchProcess:
current_app.logger.debug(f"Child process not found for session: {session_id}, creating new terminal")
terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for session: {session_id}")
return
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error(f"Failed to get child_pid for session: {session_id}")
return
# 获取文件描述符并写入数据
fd = terminal_config.get('fd')
if fd: if fd:
# print("writing to ptd: %s" % data["input"])
# os.write(fd, data["input"].encode('ascii'))
try: try:
os.write(fd, data["input"].encode()) os.write(fd, input_data.encode())
except (OSError, IOError): except (OSError, IOError) as e:
# File descriptor closed or invalid, clean up # File descriptor closed or invalid, create new terminal
disconnect() current_app.logger.debug(f"Error writing to file descriptor: {e}, creating new terminal for session: {session_id}")
session['terminal_config'] = TERM_INIT_CONFIG self.create_terminal(session_id)
return except Exception as e:
current_app.logger.error(f"Error in pty_input handling: {e}")
# 发生异常时尝试重新创建终端
try:
self.create_terminal(session_id)
except Exception as create_err:
current_app.logger.error(f"Failed to recreate terminal: {create_err}")
def on_resize(self, data): def on_resize(self, data):
try: # 获取socket_id对应的session_id
child_process = psutil.Process(session.get('terminal_config').get('child_pid')) socket_id = request.sid
except psutil.NoSuchProcess as err: with self.lock:
disconnect() session_id = self.socket_session_map.get(socket_id)
session['terminal_config'] = TERM_INIT_CONFIG
return if not session_id:
if child_process.status() not in ('running', 'sleeping'): current_app.logger.error(f"No session found for socket: {socket_id}")
disconnect()
session['terminal_config'] = TERM_INIT_CONFIG
return
fd = session.get('terminal_config').get('fd')
if fd:
# 检查文件描述符是否有效
try:
# 尝试一个简单的操作来检查文件描述符是否有效
os.fstat(fd)
set_winsize(fd, data["rows"], data["cols"])
except (OSError, IOError):
# 文件描述符无效,清理资源
disconnect()
session['terminal_config'] = TERM_INIT_CONFIG
return return
# 输入验证
if not isinstance(data, dict):
current_app.logger.error("Invalid resize data format: expected dictionary")
return
rows = data.get("rows")
cols = data.get("cols")
# 验证行和列的值是否为正整数
if not (isinstance(rows, int) and isinstance(cols, int)):
current_app.logger.error("Invalid resize dimensions: expected integers")
return
# 限制窗口大小范围,防止异常值
if rows <= 0 or rows > 1000 or cols <= 0 or cols > 1000:
current_app.logger.error(f"Invalid resize dimensions: {rows}x{cols} (out of range)")
return
try:
terminal_config = terminal_manager.get_session(session_id)
if not terminal_config:
current_app.logger.error(f"Session not found: {session_id}")
return
child_pid = terminal_config.get('child_pid')
# 如果没有子进程,重新创建终端
if not child_pid:
current_app.logger.debug(f"No child process found for resize, creating new terminal for session: {session_id}")
terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for resize, session: {session_id}")
return
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error(f"Failed to get child_pid for resize, session: {session_id}")
return
# 检查子进程是否存在且运行中
try:
child_process = psutil.Process(child_pid)
if child_process.status() not in ('running', 'sleeping'):
current_app.logger.debug(f"Child process not running for resize, creating new terminal for session: {session_id}")
terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for resize, session: {session_id}")
return
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error(f"Failed to get child_pid for resize, session: {session_id}")
return
except psutil.NoSuchProcess:
current_app.logger.debug(f"Child process not found for resize, creating new terminal for session: {session_id}")
terminal_config = self.create_terminal(session_id)
if not terminal_config:
current_app.logger.error(f"Failed to create new terminal for resize, session: {session_id}")
return
child_pid = terminal_config.get('child_pid')
if not child_pid:
current_app.logger.error(f"Failed to get child_pid for resize, session: {session_id}")
return
# 获取文件描述符并调整窗口大小
fd = terminal_config.get('fd')
if fd:
try:
os.fstat(fd)
set_winsize(fd, rows, cols)
except (OSError, IOError) as e:
# 文件描述符无效,创建新终端
current_app.logger.debug(f"Error resizing terminal: {e}, creating new terminal for session: {session_id}")
self.create_terminal(session_id)
except Exception as e:
current_app.logger.error(f"Error in resize handling: {e}")
# 发生异常时尝试重新创建终端
try:
self.create_terminal(session_id)
except Exception as create_err:
current_app.logger.error(f"Failed to recreate terminal for resize: {create_err}")
def on_disconnect(self): def on_disconnect(self):
terminal_config = session.get('terminal_config', {}) # 获取socket_id对应的session_id
socket_id = request.sid
with self.lock:
session_id = self.socket_session_map.pop(socket_id, None)
if not session_id:
current_app.logger.error(f"No session found for socket: {socket_id}")
return
# 获取会话配置
terminal_config = terminal_manager.get_session(session_id)
if not terminal_config:
current_app.logger.error(f"Session not found: {session_id}")
return
child_pid = terminal_config.get('child_pid') child_pid = terminal_config.get('child_pid')
fd = terminal_config.get('fd') fd = terminal_config.get('fd')
room_id = terminal_config.get('room_id')
current_app.logger.debug(f"Client disconnecting, session_id: {session_id}, room_id: {room_id}, socket_id: {socket_id}")
# 退出房间
if room_id:
leave_room(room_id)
current_app.logger.debug(f"Client left room: {room_id}")
# 先关闭文件描述符,避免其他操作尝试使用无效的文件描述符 # 先关闭文件描述符,避免其他操作尝试使用无效的文件描述符
if fd: if fd:
try: try:
os.close(fd) os.close(fd)
except Exception: current_app.logger.debug(f"Closed file descriptor: {fd}")
pass except Exception as e:
current_app.logger.debug(f"Error closing file descriptor: {e}")
if child_pid: if child_pid:
try: try:
@@ -230,23 +553,28 @@ class VSCLikeNameSpace(Namespace):
child_process.terminate() child_process.terminate()
# Wait for the process to terminate and collect its exit status # Wait for the process to terminate and collect its exit status
child_process.wait(timeout=2) child_process.wait(timeout=2)
current_app.logger.debug('user left the pty alone, terminated and waited') current_app.logger.debug(f'User left pty alone, terminated and waited: {child_pid}')
except psutil.NoSuchProcess as err: except psutil.NoSuchProcess:
# Process already terminated, try to wait anyway to clean up any zombie # Process already terminated, try to wait anyway to clean up any zombie
try: try:
os.waitpid(child_pid, os.WNOHANG) os.waitpid(child_pid, os.WNOHANG)
except Exception: current_app.logger.debug(f'Cleaned up zombie process: {child_pid}')
pass except Exception as e:
current_app.logger.debug(f"Error cleaning up zombie process: {e}")
except psutil.TimeoutExpired: except psutil.TimeoutExpired:
# If process didn't terminate in time, kill it forcefully # If process didn't terminate in time, kill it forcefully
try: try:
child_process.kill() child_process.kill()
child_process.wait(timeout=1) child_process.wait(timeout=1)
except Exception: current_app.logger.debug(f'Forcefully killed process: {child_pid}')
pass except Exception as e:
current_app.logger.debug(f"Error forcefully killing process: {e}")
except Exception as err: except Exception as err:
current_app.logger.error(f'Error terminating process: {err}') current_app.logger.error(f'Error terminating process: {err}')
# Reset session config # 从终端管理器中删除会话
session['terminal_config'] = TERM_INIT_CONFIG terminal_manager.delete_session(session_id)
current_app.logger.debug(f"Session deleted: {session_id}")
current_app.logger.debug(f"Client disconnected, session_id: {session_id}, room_id: {room_id}")
current_app.logger.debug('Client disconnected') current_app.logger.debug('Client disconnected')

View File

@@ -10,7 +10,6 @@ logging.getLogger('werkzeug').setLevel(logging.ERROR)
app = create_app() app = create_app()
if __name__ == '__main__': if __name__ == '__main__':
try:
socketio.run( socketio.run(
app, app,
host="0.0.0.0", host="0.0.0.0",
@@ -19,14 +18,3 @@ if __name__ == '__main__':
allow_unsafe_werkzeug=True, # 避免 dev server 的安全限制提示 allow_unsafe_werkzeug=True, # 避免 dev server 的安全限制提示
log_output=False # 禁用详细输出 log_output=False # 禁用详细输出
) )
except KeyboardInterrupt:
print("Server stopped by user")
except Exception as e:
# 捕获所有异常,包括 eventlet 内部的 IOClosed 错误
if "IOClosed" in str(type(e).__name__) or "Operation on closed file" in str(e):
# 优雅处理 eventlet 关闭文件的错误
print("Eventlet IOClosed error handled gracefully")
else:
# 其他异常仍然打印
import traceback
traceback.print_exc()

View File

@@ -161,7 +161,7 @@
// saveFile // saveFile
if (saveTimeout) clearTimeout(saveTimeout); if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => { saveTimeout = setTimeout(() => {
postSaveFile(selectedItem.path+'/'+selectedItem.name, editor.getValue()); postSaveFile(filePath, editor.getValue());
}, 5000); // 5000ms = 5秒 }, 5000); // 5000ms = 5秒
}); });