38 Commits

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
CakeCN
8aa567cdf2 全局异常捕获,特别是IOClosed错误,避免服务器崩溃 2026-01-07 01:06:13 +08:00
CakeCN
a3b3018de7 客户端断开连接后,文件描述符已经被关闭,但仍有事件在处理中:对 app/sockets/terminal_service.py 文件进行了全面修复 2026-01-07 00:57:27 +08:00
CakeCN
761095d61d 修复了code-server-like服务中的eventlet.hubs.IOClosed错误:当客户端断开连接时,文件描述符被关闭,但后台任务仍在尝试读取,导致eventlet核心代码抛出IOClosed错误。 2026-01-06 21:05:39 +08:00
14 changed files with 499 additions and 165 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

@@ -1,5 +1,209 @@
import eventlet import eventlet
eventlet.monkey_patch() # 进行猴子补丁操作 import logging
# 配置 eventlet 日志,抑制 IOClosed 等错误日志
logging.getLogger('eventlet.hubs').setLevel(logging.CRITICAL)
logging.getLogger('eventlet.greenio').setLevel(logging.CRITICAL)
# 进行猴子补丁操作
eventlet.monkey_patch()
# 配置 eventlet 以更优雅地处理错误,避免疯狂报错
try:
import eventlet
import traceback
# 1. 首先,禁用 eventlet 的调试日志,减少输出
import logging
logging.getLogger('eventlet').setLevel(logging.CRITICAL)
logging.getLogger('socketio').setLevel(logging.CRITICAL)
logging.getLogger('werkzeug').setLevel(logging.CRITICAL)
# 2. 配置 eventlet 相关设置移除了不存在的debug属性
# 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:
# 如果修改失败,打印错误但继续运行
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
from .views.file import file_bp from .views.file import file_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

@@ -29,35 +29,49 @@ terminal_bp = Blueprint('terminal', __name__, url_prefix='/vsc-like')
def set_winsize(fd, row, col, xpix=0, ypix=0): def set_winsize(fd, row, col, xpix=0, ypix=0):
winsize = struct.pack("HHHH", row, col, xpix, ypix) try:
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) winsize = struct.pack("HHHH", row, col, xpix, ypix)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
except (OSError, IOError):
# File descriptor closed or invalid, do nothing
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:
@@ -65,25 +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:
(data_ready, _, _) = select.select([fd], [], [], timeout_sec) # 使用非阻塞select检查是否有数据可读
if data_ready: (data_ready, _, _) = select.select([fd], [], [], 0)
# output = os.read(fd, max_read_bytes).decode('ascii') if data_ready:
timeout=0.1 # 有数据可读,重置超时时间
try: timeout = reset_timeout
output = os.read(fd, max_read_bytes).decode() try:
except Exception as err: # 读取数据
output = """ output = os.read(fd, max_read_bytes).decode()
***AQUI WEB TERM ERR*** except (OSError, IOError, EOFError):
{} # 文件描述符已关闭或其他IO错误优雅退出
*********************** return
""".format(err) except UnicodeDecodeError as 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! output = f"""
namespace.emit("pty_output", {"output": output}, room=room_id) ***AQUI WEB TERM ERR***
Unicode decode error: {err}
***********************
"""
# 发送数据到客户端
try:
namespace.emit("pty_output", {"output": output}, room=room_id)
except Exception:
# 如果发送失败,客户端可能已断开连接,退出
return
except (OSError, IOError):
# 文件描述符无效,优雅退出
return
except Exception as e:
# 捕获任何其他未预期的异常,防止服务器崩溃
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)
@@ -93,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()
@@ -105,79 +153,184 @@ 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 return
if child_process.status() not in ('running', 'sleeping'):
disconnect() child_process = psutil.Process(child_pid)
session['terminal_config'] = TERM_INIT_CONFIG if child_process.status() not in ('running', 'sleeping'):
return current_app.logger.debug("Child process not running, cleaning up")
# print(session) terminal_config.pop('child_pid', None)
# print(data, 'from input') terminal_config.pop('fd', None)
fd = session.get('terminal_config').get('fd') session.modified = True
if fd: return
# print("writing to ptd: %s" % data["input"])
# os.write(fd, data["input"].encode('ascii')) fd = terminal_config.get('fd')
os.write(fd, data["input"].encode()) 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 return
if child_process.status() not in ('running', 'sleeping'):
disconnect() # 检查子进程是否存在且运行中
session['terminal_config'] = TERM_INIT_CONFIG child_process = psutil.Process(child_pid)
return if child_process.status() not in ('running', 'sleeping'):
fd = session.get('terminal_config').get('fd') current_app.logger.debug("Child process not running, cleaning up resize")
if fd: terminal_config.pop('child_pid', None)
set_winsize(fd, data["rows"], data["cols"]) 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):
child_pid = session.get('terminal_config', {}).get('child_pid') terminal_config = session.get('terminal_config', {})
child_pid = terminal_config.get('child_pid')
fd = terminal_config.get('fd')
# 先关闭文件描述符,避免其他操作尝试使用无效的文件描述符
if fd:
try:
os.close(fd)
except Exception:
pass
if child_pid: if child_pid:
try: try:
child_process = psutil.Process(child_pid) child_process = psutil.Process(child_pid)
@@ -196,21 +349,14 @@ class VSCLikeNameSpace(Namespace):
pass pass
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
child_process.kill()
try: try:
child_process.kill()
child_process.wait(timeout=1) child_process.wait(timeout=1)
except Exception: except Exception:
pass pass
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}')
finally:
# Close the file descriptor if it's open # Reset session config
fd = session.get('terminal_config', {}).get('fd') session['terminal_config'] = TERM_INIT_CONFIG
if fd:
try:
os.close(fd)
except Exception:
pass
# Reset session config
session['terminal_config'] = TERM_INIT_CONFIG
current_app.logger.debug('Client disconnected') current_app.logger.debug('Client disconnected')

View File

@@ -1,5 +1,12 @@
from app import create_app from app import create_app
from app.extensions import socketio from app.extensions import socketio
import logging
# 配置日志减少eventlet的调试日志
logging.getLogger('eventlet').setLevel(logging.ERROR)
logging.getLogger('socketio').setLevel(logging.ERROR)
logging.getLogger('werkzeug').setLevel(logging.ERROR)
app = create_app() app = create_app()
if __name__ == '__main__': if __name__ == '__main__':
@@ -9,4 +16,5 @@ if __name__ == '__main__':
port=5200, port=5200,
debug=False, # 开发期打开 debug=False, # 开发期打开
allow_unsafe_werkzeug=True, # 避免 dev server 的安全限制提示 allow_unsafe_werkzeug=True, # 避免 dev server 的安全限制提示
) log_output=False # 禁用详细输出
)

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秒
}); });