Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -15,6 +15,13 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRef:
|
||||||
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||||
|
|
||||||
|
def __init__(self, session):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket(
|
||||||
"/ws/tool-instances/{instance_id}/terminal",
|
"/ws/tool-instances/{instance_id}/terminal",
|
||||||
)
|
)
|
||||||
@@ -86,9 +93,12 @@ async def terminal_websocket(
|
|||||||
# Send connected status
|
# Send connected status
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
|
||||||
|
# Use mutable session reference so loops can survive reset
|
||||||
|
session_ref = SessionRef(session)
|
||||||
|
|
||||||
# Start I/O loops and heartbeat
|
# Start I/O loops and heartbeat
|
||||||
read_task = asyncio.create_task(_read_loop(session, websocket))
|
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||||
write_task = asyncio.create_task(_write_loop(session, websocket, instance_id))
|
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
|
|
||||||
# Wait for either task to complete (indicating disconnect or error)
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
@@ -114,10 +124,14 @@ async def terminal_websocket(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _read_loop(session, websocket) -> None:
|
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
||||||
"""Read output from the container and send to WebSocket."""
|
"""Read output from the container and send to WebSocket."""
|
||||||
try:
|
try:
|
||||||
while session.is_alive() and not session._closed:
|
while True:
|
||||||
|
session = session_ref.session
|
||||||
|
if not session.is_alive() or session._closed:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
data = await session.read_output()
|
data = await session.read_output()
|
||||||
if data:
|
if data:
|
||||||
try:
|
try:
|
||||||
@@ -130,10 +144,14 @@ async def _read_loop(session, websocket) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _write_loop(session, websocket, instance_id: str) -> None:
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
"""Read input from WebSocket and send to container."""
|
"""Read input from WebSocket and send to container."""
|
||||||
try:
|
try:
|
||||||
while session.is_alive() and not session._closed:
|
while True:
|
||||||
|
session = session_ref.session
|
||||||
|
if not session.is_alive() or session._closed:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
message = await websocket.receive()
|
message = await websocket.receive()
|
||||||
if message["type"] == "websocket.receive":
|
if message["type"] == "websocket.receive":
|
||||||
if "bytes" in message:
|
if "bytes" in message:
|
||||||
@@ -163,17 +181,19 @@ async def _write_loop(session, websocket, instance_id: str) -> None:
|
|||||||
session.container_id,
|
session.container_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Update the mutable session reference so read_loop uses the new session
|
||||||
|
session_ref.session = new_session
|
||||||
|
|
||||||
# Attach to new session
|
# Attach to new session
|
||||||
await terminal_manager.attach_websocket(new_session, websocket)
|
await terminal_manager.attach_websocket(new_session, websocket)
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
|
||||||
# Update session reference and restart loops
|
# Continue the loop with the new session
|
||||||
# Note: This will cause the current loops to exit
|
continue
|
||||||
# The WebSocket handler will create new ones
|
|
||||||
return
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
# Not a valid JSON control message, treat as regular input
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
else:
|
else:
|
||||||
await session.write_input(text.encode("utf-8"))
|
await session.write_input(text.encode("utf-8"))
|
||||||
elif message["type"] == "websocket.disconnect":
|
elif message["type"] == "websocket.disconnect":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
import select
|
import select
|
||||||
|
import signal
|
||||||
import struct
|
import struct
|
||||||
import fcntl
|
import fcntl
|
||||||
import time
|
import time
|
||||||
@@ -152,16 +153,19 @@ class TerminalSession:
|
|||||||
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
logger.info(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||||
self._set_terminal_size(cols, rows)
|
self._set_terminal_size(cols, rows)
|
||||||
|
|
||||||
# Docker exec doesn't forward PTY resize to the container process,
|
# Docker exec -it creates its own PTY inside the container,
|
||||||
# so we need to explicitly set the size inside the container shell.
|
# so host PTY resize doesn't propagate to the container shell.
|
||||||
# Send on every resize so the container shell always matches the frontend.
|
# Send SIGWINCH to the docker exec process on the host.
|
||||||
# Use stty -echo to prevent the command from being visible, then clear the line.
|
# Docker exec forwards signals to the container process, which should
|
||||||
stty_cmd = (
|
# cause the container's shell to re-read its terminal size.
|
||||||
f"stty -echo; stty cols {cols} rows {rows}; stty echo\n"
|
if self.process and self.process.pid:
|
||||||
f"\x1b[A\x1b[M" # Move up 1 line and delete it (clears the stty command)
|
try:
|
||||||
).encode()
|
os.kill(self.process.pid, signal.SIGWINCH)
|
||||||
await self.write_input(stty_cmd)
|
logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}")
|
||||||
logger.debug(f"Sent stty resize to container for session {self.session_id}: {cols}x{rows}")
|
except ProcessLookupError:
|
||||||
|
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send SIGWINCH: {e}")
|
||||||
|
|
||||||
async def reset(self) -> None:
|
async def reset(self) -> None:
|
||||||
"""Reset the session by killing the process and clearing state."""
|
"""Reset the session by killing the process and clearing state."""
|
||||||
|
|||||||
@@ -115,6 +115,20 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
if (msg.status === "connected") {
|
if (msg.status === "connected") {
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// Clear terminal and refit after reset/reconnect
|
||||||
|
if (termRef.current) {
|
||||||
|
termRef.current.clear();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (fitAddonRef.current && termRef.current) {
|
||||||
|
fitAddonRef.current.fit();
|
||||||
|
const { cols, rows } = termRef.current;
|
||||||
|
const currentWs = wsRef.current;
|
||||||
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||||
|
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (msg.status === "resetting") {
|
} else if (msg.status === "resetting") {
|
||||||
setStatus("resetting");
|
setStatus("resetting");
|
||||||
}
|
}
|
||||||
@@ -210,11 +224,7 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
const container = terminalRef.current;
|
const container = terminalRef.current;
|
||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
|
|
||||||
// Open xterm immediately
|
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
||||||
term.open(container);
|
|
||||||
ws = connectWebSocket();
|
|
||||||
|
|
||||||
// Fit terminal and notify backend
|
|
||||||
const fitTerminal = () => {
|
const fitTerminal = () => {
|
||||||
if (!fitAddonRef.current || !termRef.current) return;
|
if (!fitAddonRef.current || !termRef.current) return;
|
||||||
const oldCols = termRef.current.cols;
|
const oldCols = termRef.current.cols;
|
||||||
@@ -225,8 +235,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
if (cols !== oldCols || rows !== oldRows) {
|
if (cols !== oldCols || rows !== oldRows) {
|
||||||
termRef.current.refresh(0, rows - 1);
|
termRef.current.refresh(0, rows - 1);
|
||||||
}
|
}
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
const currentWs = wsRef.current;
|
||||||
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||||
|
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -237,6 +248,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Open xterm immediately
|
||||||
|
term.open(container);
|
||||||
|
ws = connectWebSocket();
|
||||||
|
|
||||||
// Refit after font load (metrics may change)
|
// Refit after font load (metrics may change)
|
||||||
document.fonts.ready.then(() => {
|
document.fonts.ready.then(() => {
|
||||||
requestAnimationFrame(() => fitTerminal());
|
requestAnimationFrame(() => fitTerminal());
|
||||||
@@ -244,20 +259,21 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
|
|
||||||
// Handle terminal input
|
// Handle terminal input
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
const currentWs = wsRef.current;
|
||||||
|
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
// Apply active modifier to single-character input
|
// Apply active modifier to single-character input
|
||||||
const modifier = activeModifierRef.current;
|
const modifier = activeModifierRef.current;
|
||||||
if (modifier && data.length === 1) {
|
if (modifier && data.length === 1) {
|
||||||
const modified = applyModifierToChar(data, modifier);
|
const modified = applyModifierToChar(data, modifier);
|
||||||
if (modified) {
|
if (modified) {
|
||||||
ws.send(modified);
|
currentWs.send(modified);
|
||||||
onModifierChange?.(null);
|
onModifierChange?.(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(data);
|
currentWs.send(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle container resize with ResizeObserver for accurate dimension tracking
|
// Handle container resize with ResizeObserver for accurate dimension tracking
|
||||||
@@ -302,8 +318,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
|
|||||||
// Notify parent about terminal readiness
|
// Notify parent about terminal readiness
|
||||||
if (onTerminalReadyRef.current) {
|
if (onTerminalReadyRef.current) {
|
||||||
const sendData = (data: string) => {
|
const sendData = (data: string) => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
const currentWs = wsRef.current;
|
||||||
ws.send(data);
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||||
|
currentWs.send(data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const focusInput = () => {
|
const focusInput = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user