35 Commits
test ... main

Author SHA1 Message Date
CakeCN
36bbedd3cf no caddy reload 2026-01-09 12:06:55 +08:00
CakeCN
0e3703b1f4 more log 2026-01-09 11:45:17 +08:00
CakeCN
52696707dd log in connect 2026-01-09 11:31:00 +08:00
CakeCN
0a812e496f fix namespace init 2026-01-09 11:24:13 +08:00
CakeCN
de1ba08d7e 修改了 AgentNamespace 类,将全局实例变量替换为字典,使用用户 UUID 作为键 2026-01-09 11:23:02 +08:00
CakeCN
3f14cbd6c1 some log 2026-01-09 11:16:30 +08:00
CakeCN
c51565199c more log 2026-01-09 01:40:36 +08:00
CakeCN
8d29a26d7d no connect on server 2026-01-09 01:27:24 +08:00
CakeCN
decf24020a use user-joined 2026-01-09 01:22:29 +08:00
CakeCN
3dc219adc2 use join 2026-01-09 01:19:08 +08:00
CakeCN
40d1341685 more set alwaysconnect to True 2026-01-09 00:58:46 +08:00
CakeCN
0d606fb171 ,always_connect=True 2026-01-09 00:56:56 +08:00
CakeCN
e1f32ec980 ase_client add user-connected 2026-01-09 00:04:31 +08:00
CakeCN
ad8d7d069c 增加log 2026-01-08 23:54:27 +08:00
CakeCN
687b316db6 删去earning_progess_process的log 2026-01-08 23:51:55 +08:00
CakeCN
60248d5c51 log connect_to_ase 2026-01-08 23:38:50 +08:00
CakeCN
befec5543c fix: 前端打开codelike 2026-01-08 21:06:11 +08:00
CakeCN
af24c51a2f fix previous 前端发送用户信息 2026-01-08 21:04:37 +08:00
CakeCN
fa91696aae fix previous 2026-01-08 21:02:44 +08:00
CakeCN
e698c50a4b 调整前端user-connected 2026-01-08 21:01:11 +08:00
CakeCN
75795dd853 fix previous 2026-01-08 20:52:51 +08:00
CakeCN
5b8180b48e 将join room提前到on_connect 2026-01-08 20:50:56 +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
14 changed files with 447 additions and 196 deletions

View File

@@ -39,16 +39,19 @@ class ASEngineClient:
# 连接事件 # 连接事件
@self.sio.on('connect', namespace=self.namespace) @self.sio.on('connect', namespace=self.namespace)
def on_connect(): def on_connect():
print("---------------ASEngineClient on_connect----------------")
self.sid = self.sio.sid
print(f"Connected to server with sid: {self.sid}")
self.connected = True self.connected = True
self.sid = self.sio.get_sid(namespace=self.namespace) self.sio.emit('join', {'id': self.id}, namespace=self.namespace)
print(f"Connected to server. SID: {self.sid}")
if self._on_connect_callback is not None: if self._on_connect_callback is not None:
self._on_connect_callback( self._on_connect_callback(
self, self,
self._on_connect_callback_entry, self._on_connect_callback_entry,
init_data=self._on_connect_callback_initData init_data=self._on_connect_callback_initData
) )
# 断开连接事件 # 断开连接事件
@self.sio.on('disconnect', namespace=self.namespace) @self.sio.on('disconnect', namespace=self.namespace)
def on_disconnect(): def on_disconnect():

View File

@@ -14,7 +14,7 @@ from typing import Optional
mongo = PyMongo() mongo = PyMongo()
socketio = SocketIO(cors_allowed_origins="*",max_payload_size=52428800, async_mode="eventlet") # 不直接传 app socketio = SocketIO(cors_allowed_origins="*",max_payload_size=52428800, async_mode="eventlet",always_connect=False) # 不直接传 app
cors = CORS() cors = CORS()
# ===== 你的全局对象 ===== # ===== 你的全局对象 =====
# Backboard # Backboard
@@ -38,7 +38,7 @@ def init_extensions(app):
cors_allowed_origins='*',#app.config["VSCODE_WEB_URL"], cors_allowed_origins='*',#app.config["VSCODE_WEB_URL"],
ping_timeout=app.config["SOCKETIO_PING_TIMEOUT"], ping_timeout=app.config["SOCKETIO_PING_TIMEOUT"],
ping_interval=app.config["SOCKETIO_PING_INTERVAL"], ping_interval=app.config["SOCKETIO_PING_INTERVAL"],
max_payload_size=50000000, async_mode="eventlet" max_payload_size=50000000, async_mode="eventlet",always_connect=False
) )
cors.init_app( cors.init_app(
app, app,

View File

@@ -1,6 +1,4 @@
import requests import requests
from flask_socketio import Namespace
def clear_user_session(asengine_url, asengine_token, user_id): def clear_user_session(asengine_url, asengine_token, user_id):
''' '''
@@ -28,9 +26,12 @@ def on_connect_to_ase(client, entry, init_data, **kwargs):
''' '''
此时刚刚建立好aseclient连接并向前端发送打开code-server-like指令。 此时刚刚建立好aseclient连接并向前端发送打开code-server-like指令。
''' '''
namespace_entry, ase_client = entry namespace_entry, ase_client, logger = entry
logger.info(f"on_connect_to_ase with init_data: {init_data}")
namespace_entry.emit('open-iframe',"", room=init_data['room'], namespace='/agent') namespace_entry.emit('open-iframe',"", room=init_data['room'], namespace='/agent')
namespace_entry.emit('message', "Agents服务已连接, 加载教案中", room=init_data['room'], namespace='/agent') namespace_entry.emit('message', "Agents服务已连接, 加载教案中", room=init_data['room'], namespace='/agent')
namespace_entry.emit('message', f'{init_data}', room=init_data['room'], namespace='/agent')
restart = init_data.get("restart", False) restart = init_data.get("restart", False)
# 不再需要手动next_chapter因为已经在on_login中根据mongo进度恢复了正确的章节 # 不再需要手动next_chapter因为已经在on_login中根据mongo进度恢复了正确的章节

View File

@@ -93,7 +93,6 @@ def get_multiagents_dialog_list(user_id: str) -> List[Dict]:
# 检查响应 # 检查响应
if response.status_code == 200: if response.status_code == 200:
current_app.logger.info(f"获取MultiAgents对话列表成功: user_id={user_id}, json={response.json()}")
return response.json() return response.json()
else: else:
current_app.logger.error(f"获取MultiAgents对话列表失败: HTTP {response.status_code}") current_app.logger.error(f"获取MultiAgents对话列表失败: HTTP {response.status_code}")

View File

@@ -63,6 +63,12 @@ class VSCodeNamespace(Namespace):
class AgentNamespace(Namespace): class AgentNamespace(Namespace):
def __init__(self, namespace):
super().__init__(namespace)
# 使用字典存储每个用户的chatmanager和ase_client避免多用户冲突
self.user_chatmanagers = {}
self.user_ase_clients = {}
def _load_learning_progress(self, chatmanager, user_id, course_id, chapter_name, lesson_name, continue_learn): def _load_learning_progress(self, chatmanager, user_id, course_id, chapter_name, lesson_name, continue_learn):
"""加载并恢复用户学习进度 """加载并恢复用户学习进度
@@ -120,8 +126,8 @@ class AgentNamespace(Namespace):
chapter_name = data['chapter_name'] chapter_name = data['chapter_name']
user_id = uuid2username[user_uuid] user_id = uuid2username[user_uuid]
session['user_uuid'] = user_uuid session['user_uuid'] = user_uuid
join_room(user_uuid, namespace='/agent') # 将该用户加入以user_id为名的room
print(f'User connected with session user_uuid: {user_uuid}') print(f'User connected with session user_uuid: {user_uuid}')
join_room(user_uuid)
user_uuid2chatmanager = ex["user_uuid2chatmanager"] user_uuid2chatmanager = ex["user_uuid2chatmanager"]
user_uuid2ase_client = ex["user_uuid2ase_client"] user_uuid2ase_client = ex["user_uuid2ase_client"]
@@ -152,8 +158,9 @@ class AgentNamespace(Namespace):
if chatmanager.chat_historys: if chatmanager.chat_historys:
emit('messages', chatmanager.chat_historys, room=user_uuid, namespace='/agent') emit('messages', chatmanager.chat_historys, room=user_uuid, namespace='/agent')
self.chatmanager = chatmanager # 存储每个用户的chatmanager和ase_client到字典中使用用户UUID作为键
self.ase_client = user_uuid2ase_client[user_uuid] self.user_chatmanagers[user_uuid] = chatmanager
self.user_ase_clients[user_uuid] = user_uuid2ase_client[user_uuid]
# 将restart设置为False避免后续重复处理 # 将restart设置为False避免后续重复处理
restart = chatmanager.restart restart = chatmanager.restart
@@ -161,14 +168,14 @@ class AgentNamespace(Namespace):
user_uuid2ase_client[user_uuid].register_on_connect_entry( user_uuid2ase_client[user_uuid].register_on_connect_entry(
callback=on_connect_to_ase, callback=on_connect_to_ase,
entry=(self, user_uuid2ase_client[user_uuid]), entry=(self, user_uuid2ase_client[user_uuid], current_app.logger),
init_data={'room':user_uuid, 'restart':restart} init_data={'room':user_uuid, 'restart':restart}
) )
user_uuid2ase_client[user_uuid].register_route_with_entry( user_uuid2ase_client[user_uuid].register_route_with_entry(
route='dialog', route='dialog',
callback=receive_ase_dialog, callback=receive_ase_dialog,
entry=self, entry=self,
init_data={'room': user_uuid} init_data={'room':user_uuid}
) )
user_uuid2ase_client[user_uuid].register_route_with_entry( user_uuid2ase_client[user_uuid].register_route_with_entry(
route='dialog-hint', route='dialog-hint',
@@ -186,13 +193,13 @@ class AgentNamespace(Namespace):
route='judge', route='judge',
callback=receive_ase_judge, callback=receive_ase_judge,
entry=self, entry=self,
init_data={'room': user_uuid} init_data={'room':user_uuid}
) )
user_uuid2ase_client[user_uuid].register_route_with_entry( user_uuid2ase_client[user_uuid].register_route_with_entry(
route='next_chapter', route='next_chapter',
callback=receive_ase_next_chapter, callback=receive_ase_next_chapter,
entry=self, entry=self,
init_data={'room': user_uuid} init_data={'room':user_uuid}
) )
user_uuid2ase_client[user_uuid].register_route_with_entry( user_uuid2ase_client[user_uuid].register_route_with_entry(
route='chapter_score', route='chapter_score',
@@ -286,4 +293,4 @@ class AgentNamespace(Namespace):
leave_room(uuid, namespace='/agent') leave_room(uuid, namespace='/agent')
self.app.logger.info("VSCode client disconnected") self.app.logger.info("VSCode client disconnected")
self.app.logger.info(f"Disconnect reason: {str(data)}") self.app.logger.info(f"Disconnect reason: {str(data)}")

View File

@@ -66,31 +66,29 @@ function cleanupExpiredApprovals() {
// 定时清理过期记录 // 定时清理过期记录
setInterval(cleanupExpiredApprovals, 3000); // 每3秒检查一次 setInterval(cleanupExpiredApprovals, 3000); // 每3秒检查一次
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
data = window.appData; user_data = window.appData;
const host = window.location.host; const host = window.location.host;
console.log(`Connecting to WebSocket server at wss://${host}/agent`); console.log(`Connecting to WebSocket server at wss://${host}/agent`);
socket = io(`wss://${host}/agent`, { socket = io(`wss://${host}/agent`, {
query: { query: {
user_uuid: data.user_uuid, user_uuid: user_data.user_uuid,
folder: data.folder folder: user_data.folder
} }
}); });
// 初始化聊天消息管理器 // 初始化聊天消息管理器
chatManager = new ChatMessageManager(); chatManager = new ChatMessageManager();
socket.on('connect', function () { socket.on('connect', function () {
console.log('Connected to server'); console.log('Connected to WebSocket server'+socket.id+"with user"+JSON.stringify(user_data));
socket.emit('login',JSON.stringify(data)); socket.emit('login',JSON.stringify(user_data));
}); });
socket.on('open-iframe', function(data) { socket.on('open-iframe', function() {
OpenIframe(); OpenIframe(user_data);
}); });
// 监听来自服务器的消息 // 监听来自服务器的消息
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

@@ -208,7 +208,7 @@
console.error('Error fetching session:', error); console.error('Error fetching session:', error);
}); });
let lastiframe = null; let lastiframe = null;
function OpenIframe(){ function OpenIframe(data){
// 等待1s再加载 // 等待1s再加载
setTimeout(() => { setTimeout(() => {
// 在成功获取 session 后创建并插入 iframe // 在成功获取 session 后创建并插入 iframe

View File

@@ -187,7 +187,7 @@
let lastIframe = null; let lastIframe = null;
function OpenIframe(){ function OpenIframe(data){
// 在成功获取 session 后创建并插入 iframe // 在成功获取 session 后创建并插入 iframe
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
const div=document.getElementById('vscodeWeb') const div=document.getElementById('vscodeWeb')

View File

@@ -91,38 +91,6 @@ def desktop(user_uuid, course_id, chapter_name, lesson_name):
caddy_conf = f"""
handle_path /vsc{user_id}/* {{
reverse_proxy localhost:{code_server_port}
}}
"""
caddy_config_path = "/etc/caddy/Caddyfile"
with open(caddy_config_path, "r") as f:
lines = f.readlines()
pattern = re.compile(
r'handle_path\s+/vsc' + re.escape(user_id) + r'/\* {.*?reverse_proxy.*?}',
re.DOTALL
)
for i in range(len(lines)):
if (lines[i].strip()==''): lines[i]=''
content = "".join(lines)
if pattern.search(content):
new_content = pattern.sub('', content)
lines = new_content.splitlines(keepends=True)
if len(lines) >= 2:
insert_position = len(lines) - 2
else:
insert_position = len(lines)
if caddy_conf[-1] not in ['\n', '\r']:
caddy_conf += '\n'
lines.insert(insert_position, caddy_conf)
with open(caddy_config_path, "w") as f:
f.writelines(lines)
try:
subprocess.run(["sudo", "caddy", "reload", "--config", "/etc/caddy/Caddyfile"])
except subprocess.CalledProcessError as e:
print(f"Error reload caddy {e}")
cfg = current_app.config["VSCODE_WEB_PATH"] cfg = current_app.config["VSCODE_WEB_PATH"]
path_for_vscode = path_dir path_for_vscode = path_dir

View File

@@ -8,24 +8,201 @@ logging.getLogger('eventlet.greenio').setLevel(logging.CRITICAL)
# 进行猴子补丁操作 # 进行猴子补丁操作
eventlet.monkey_patch() eventlet.monkey_patch()
# 配置 eventlet hub 以更优雅地处理 IOClosed 错误 # 配置 eventlet 以更优雅地处理错误,避免疯狂报错
try: try:
from eventlet.hubs import hub import eventlet
# 保存原始的 handle_error 方法 import traceback
original_handle_error = hub.Hub.handle_error
def custom_handle_error(self, context, type, value, tb): # 1. 首先,禁用 eventlet 的调试日志,减少输出
# 忽略 IOClosed 错误 import logging
if type.__name__ == 'IOClosed' and 'Operation on closed file' in str(value): logging.getLogger('eventlet').setLevel(logging.CRITICAL)
return logging.getLogger('socketio').setLevel(logging.CRITICAL)
# 其他错误调用原始方法 logging.getLogger('werkzeug').setLevel(logging.CRITICAL)
original_handle_error(self, context, type, value, tb)
# 替换原始方法 # 2. 配置 eventlet 相关设置移除了不存在的debug属性
hub.Hub.handle_error = custom_handle_error
# 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:
from flask_socketio import SocketIO
# 获取当前 socketio 实例并配置错误处理
import sys
original_excepthook = sys.excepthook
def custom_excepthook(exc_type, exc_value, exc_traceback):
"""全局异常钩子,捕获所有未处理的异常"""
if exc_type.__name__ in ['OSError', 'IOError'] and 'Bad file descriptor' in str(exc_value):
# 处理 Bad file descriptor 错误,不导致服务器宕机
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
# 其他异常调用原始的 excepthook
original_excepthook(exc_type, exc_value, exc_traceback)
# 设置全局异常钩子
sys.excepthook = custom_excepthook
except Exception as e:
# 如果修改失败,忽略错误
print(f"[Global Excepthook Patch Error] {e}")
from eventlet import greenio
# 保存并替换原始的 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
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: except Exception as e:
# 如果修改失败,忽略错误 # 如果修改失败,打印错误但继续运行
pass 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

@@ -3,12 +3,12 @@ from flask_socketio import SocketIO
from .config import Config from .config import Config
# 初始化扩展 # 初始化扩展
cors = CORS() cors = CORS()
socketio = SocketIO(path='vsc-like-ws',cors_allowed_origins="*",max_payload_size=5242880, async_mode="eventlet") # 不直接传 app socketio = SocketIO(path='vsc-like-ws',cors_allowed_origins="*",max_payload_size=5242880, async_mode="eventlet",always_connect=False) # 不直接传 app
session_paths = {} session_paths = {}
def init_extensions(app): def init_extensions(app):
cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True) cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
socketio.init_app(app, cors_allowed_origins="*", max_payload_size=5242880, async_mode="eventlet") socketio.init_app(app, cors_allowed_origins="*", max_payload_size=5242880, async_mode="eventlet",always_connect=False)
app.extensions["session_paths"] = session_paths app.extensions["session_paths"] = session_paths
def register_namespaces(app): def register_namespaces(app):

View File

@@ -37,31 +37,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 +79,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)
@@ -111,10 +125,26 @@ def read_and_forward_pty_output(fd=None, pid=None, room_id=None,namespace=None):
class VSCLikeNameSpace(Namespace): class VSCLikeNameSpace(Namespace):
def on_connect(self): def on_connect(self):
"""new client connected""" """new client connected"""
if session.get('terminal_config', {}).get('child_pid', None): # 确保terminal_config存在于session中
print(session['terminal_config']['child_pid']) if 'terminal_config' not in session:
# already started child process, don't start another session['terminal_config'] = TERM_INIT_CONFIG.copy()
return session.modified = True
terminal_config = session['terminal_config']
# 检查是否已经有运行中的子进程
if terminal_config.get('child_pid'):
try:
# 验证进程是否真的存在
child_process = psutil.Process(terminal_config['child_pid'])
if child_process.status() in ('running', 'sleeping'):
current_app.logger.debug("Already running child process: {}".format(terminal_config['child_pid']))
return
except psutil.NoSuchProcess:
# 进程不存在,清理配置
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
# 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()
@@ -123,91 +153,171 @@ class VSCLikeNameSpace(Namespace):
# 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) terminal_config = session.get('terminal_config', {})
if not path: term_type = terminal_config.get('term_type')
print("Can't locate {} binary, exit".format(term_type))
disconnect() if not term_type:
if term_type == 'telnet': print("Terminal type not specified, exit")
# switch to the right location of your telnet binary (example comes from OSX which got telnet from brew) os._exit(1)
# or you can also make work like auto-detection, or manually but configurable
os.execl(path, 'telnet', '-l', session['terminal_config']['username'], path = TERM_INIT_CONFIG.get('client_path', {}).get(term_type, None)
session['terminal_config']['domain'], '{}'.format(session['terminal_config']['port'])) if not path or not os.path.exists(path):
elif term_type == 'ssh': print("Can't locate {} binary at {}, exit".format(term_type, path))
os.execl(path,'ssh', '-p','22', os._exit(1)
#'{}'.format(session['terminal_config']['port']),
'{}@{}'.format(session['terminal_config']['username'], session['terminal_config']['domain'])) # 获取连接参数
username = terminal_config.get('username')
domain = terminal_config.get('domain', TERM_INIT_CONFIG['domain'])
else: port = terminal_config.get('port')
current_app.logger.debug("wrong term type {}".format(term_type))
disconnect() if not username or not domain:
session['terminal_config'] = TERM_INIT_CONFIG print("Missing required connection parameters, exit")
os._exit(1)
if term_type == 'telnet':
# 使用telnet连接
if not port:
print("Port not specified for telnet, exit")
os._exit(1)
os.execl(path, 'telnet', '-l', username, domain, str(port))
elif term_type == 'ssh':
# 使用ssh连接
ssh_port = port if port else 22 # 默认端口22
os.execl(path, 'ssh', '-p', str(ssh_port), '{}@{}'.format(username, domain))
else:
print("Wrong term type {}, exit".format(term_type))
os._exit(1)
except Exception as e:
print("Error in child process: {}", str(e))
os._exit(1)
else: else:
session['terminal_config']['fd'] = fd # 更新会话配置
session['terminal_config']['child_pid'] = child_pid terminal_config['fd'] = fd
session['terminal_config']['room_id'] = rooms()[0] terminal_config['child_pid'] = child_pid
terminal_config['room_id'] = rooms()[0]
session.modified = True session.modified = True
# 设置初始窗口大小
set_winsize(fd, 50, 50) set_winsize(fd, 50, 50)
# 记录日志
current_app.logger.debug("child pid = {}".format(child_pid)) current_app.logger.debug("child pid = {}".format(child_pid))
current_app.logger.debug("rooms of this session = {}".format(rooms())) 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)
# 启动后台任务读取pty输出
socketio.start_background_task(read_and_forward_pty_output, fd, child_pid, rooms()[0], self)
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}") # 输入验证
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: try:
child_process = psutil.Process(session.get('terminal_config').get('child_pid')) terminal_config = session.get('terminal_config', {})
except psutil.NoSuchProcess as err: child_pid = terminal_config.get('child_pid')
disconnect() if not child_pid:
session['terminal_config'] = TERM_INIT_CONFIG current_app.logger.error("No child process found")
return
if child_process.status() not in ('running', 'sleeping'):
disconnect()
session['terminal_config'] = TERM_INIT_CONFIG
return
# print(session)
# print(data, 'from input')
fd = session.get('terminal_config').get('fd')
if fd:
# print("writing to ptd: %s" % data["input"])
# os.write(fd, data["input"].encode('ascii'))
try:
os.write(fd, data["input"].encode())
except (OSError, IOError):
# File descriptor closed or invalid, clean up
disconnect()
session['terminal_config'] = TERM_INIT_CONFIG
return return
child_process = psutil.Process(child_pid)
if child_process.status() not in ('running', 'sleeping'):
current_app.logger.debug("Child process not running, cleaning up")
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
return
fd = terminal_config.get('fd')
if fd:
try:
os.write(fd, input_data.encode())
except (OSError, IOError) as e:
# File descriptor closed or invalid, clean up
current_app.logger.debug(f"Error writing to file descriptor: {e}")
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
except psutil.NoSuchProcess:
# Process no longer exists, clean up
terminal_config = session.get('terminal_config', {})
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
except Exception as e:
current_app.logger.error(f"Error in pty_input handling: {e}")
def on_resize(self, data): def on_resize(self, data):
# 输入验证
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: try:
child_process = psutil.Process(session.get('terminal_config').get('child_pid')) terminal_config = session.get('terminal_config', {})
except psutil.NoSuchProcess as err: child_pid = terminal_config.get('child_pid')
disconnect() if not child_pid:
session['terminal_config'] = TERM_INIT_CONFIG current_app.logger.error("No child process found for resize")
return
if child_process.status() not in ('running', 'sleeping'):
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
# 检查子进程是否存在且运行中
child_process = psutil.Process(child_pid)
if child_process.status() not in ('running', 'sleeping'):
current_app.logger.debug("Child process not running, cleaning up resize")
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
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}")
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
except psutil.NoSuchProcess:
# 进程不存在,清理资源
terminal_config = session.get('terminal_config', {})
terminal_config.pop('child_pid', None)
terminal_config.pop('fd', None)
session.modified = True
except Exception as e:
current_app.logger.error(f"Error in resize handling: {e}")
def on_disconnect(self): def on_disconnect(self):
terminal_config = session.get('terminal_config', {}) terminal_config = session.get('terminal_config', {})

View File

@@ -10,23 +10,11 @@ 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", port=5200,
port=5200, debug=False, # 开发期打开
debug=False, # 开发期打开 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秒
}); });