Работа с несколькими клиентами в браузерном терминале, созданном с помощью xtermjs

Мое браузерное терминальное приложение работает без проблем, пока оно используется одним клиентом, но когда другой клиент подключается к нему и пытается использовать тот же терминал, он отображается для нового клиента. Также, когда я открываю его на другой вкладке, он тоже показывает тот же терминал. Определение отдельных терминалов в xtermjs, таких как termA и termB, не является жизнеспособным, поскольку количество клиентов не определено. Приложение работает с django в бэкенде. Xtermjs используется только для визуализации и получения пользовательского ввода. Псевдотерминал и процесс bash обрабатывают терминальную логику в django. Мой первый подход состоял в том, чтобы реализовать многопроцессорность в django, но мне не удалось этого добиться. Я открыт для различных подходов к решению этой проблемы.

views.py:

import os
from django.shortcuts import render
import socketio
import pty
import select
import subprocess
import struct
import fcntl
import termios
import signal
import eventlet    

async_mode = "eventlet"
sio = socketio.Server(async_mode=async_mode)

fd = None
child_pid = None

def index(request):
    return render(request, "index.html")

def set_winsize(fd, row, col, xpix=0, ypix=0):
    winsize = struct.pack("HHHH", row, col, xpix, ypix)
    fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)


def read_and_forward_pty_output():
    global fd
    max_read_bytes = 1024 * 20
    while True:
        sio.sleep(0.01)
        if fd:
            timeout_sec = 0
            (data_ready, _, _) = select.select([fd], [], [], timeout_sec)
            if data_ready:
                output = os.read(fd, max_read_bytes).decode()
                sio.emit("pty_output", {"output": output})
        else:
            print("process killed")
            return


@sio.event
def resize(sid, message):
    if fd:
        set_winsize(fd, message["rows"], message["cols"])
    

@sio.event
def pty_input(sid, message):
    if fd:
        os.write(fd, message["input"].encode())


@sio.event
def disconnect_request(sid):
    sio.disconnect(sid)


@sio.event
def connect(sid, environ):
    global fd
    global child_pid

    if child_pid:
        os.write(fd, "\n".encode())
        return

    (child_pid, fd) = pty.fork()

    if child_pid == 0:
        subprocess.run("clear")
        subprocess.run('bash')

    else:
        sio.start_background_task(target=read_and_forward_pty_output)


@sio.event
def disconnect(sid):

    global fd
    global child_pid

    os.kill(child_pid,signal.SIGKILL)
    os.wait()

    fd = None
    child_pid = None
    print('Client disconnected')

index.html:

<html>

<head>
    {% load static %}
    <link rel="stylesheet" href="https://unpkg.com/xterm@5.2.1/css/xterm.css" />

    <script src="https://unpkg.com/xterm@5.2.1/lib/xterm.js"></script>
    <script src="https://unpkg.com/xterm-addon-fit@0.7.0/lib/xterm-addon-fit.js"></script>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.4.0/socket.io.js"></script>
</head>

<body>

    <div class="main" style="margin-left: 5%; position:sticky;">

        <div style="background: white; padding-bottom: 5px; margin-top:1%;">
            <span>Status: <span style="font-size: small; background-color: #d9ff00;"
                    id="status">connecting...</span></span>
            <button id="button" ; type="button" ; onclick="myFunction()" ; style="padding:0.2%;">Connect</button>
        </div>

        <div style=" width: 98%; height:50%;" id="terminal"></div>

    </div>

    <script>

        var socket = io.connect({ transports: ["websocket", "polling"], autoConnect: false });

        const status = document.getElementById("status")
        const button = document.getElementById("button")
        const fit = new FitAddon.FitAddon();

        // Clear sessionStorage when the page is reloaded or closed
        window.addEventListener('beforeunload', () => {
            sessionStorage.clear();
        });

        var term = new Terminal({
            cursorBlink: true,
        });

        term.loadAddon(fit);
        term.open(document.getElementById('terminal'));
        fit.fit();


        term.onKey(e => {
            socket.emit("pty_input", { "input": e.key });
        })

        socket.on("pty_output", function (output) {
            term.write(output["output"]);
        })

        socket.on("connect", () => {
            status.innerHTML = '<span style="background-color: lightgreen;">connected</span>'
            resize()
            button.innerHTML = 'Disconnect'
        })

        socket.on("disconnect", () => {
            status.innerHTML = '<span style="background-color: #ff8383;">disconnected</span>'
            button.innerHTML = 'Connect'
        })

        function myFunction() {
            if (button.innerHTML == 'Connect') {
                console.log("conn req")
                socket.connect();
            }

            else if (button.innerHTML == "Disconnect") {
                socket.emit("disconnect_request")
            }
        }

        function resize() {
            console.log("resized")
            fit.fit()
            socket.emit("resize", { "cols": term.cols, "rows": term.rows })
        }
        window.onresize = function () {
            console.log("size changed")
        }
        window.onresize = resize
        window.onload = resize


    </script>
</body>

</html>

Я думаю, что лучше сначала инкапсулировать pty в класс, так как это делает вещи более понятными (взято из моего собственного django-based terminal server impl):

Это, по сути, дает вам удобный класс для таких взаимодействий с PTY, как спаун, чтение/запись, размер и (принудительное) убийство.

Теперь вы можете настроить разделение терминальных сессий, например, в файле views.py. Для "классического" long-polling (никогда не работал с SocketIO):

from pty_terminal import PtyTerminal
from django.http import HttpResponse

terminals = {}

def create(request):
    # do some sort of auth/permission checks here,
    # otherwise anyone can flood your server with PTYs
    ...
    token = uuid4().hex
    terminal = PtyTerminal()
    terminals[token] = terminal
    return HttpResponse(token, content_type='text/plain')

def read(request, token):
    # auth/permission checks again
    ...
    terminal = terminals[token]
    read_data = terminal.read(length=65536, timeout=10)
    return HttpResponse(read_data, content_type='application/octet-stream')

def write(request, token):
    # auth/permission checks again
    ...
    terminal = terminals[token]
    terminal.write(request.body)
    return HttpResponse('')

def resize(request, token):
    # likewise as above
    ...

Итак, это базовая схема работы с long-polling. Не хватает нескольких вещей, таких как очистка время от времени, чтобы избавиться от устаревших терминалов, правильная обработка того, когда ведомая сторона PTY была наконец закрыта или некоторые потоковые мьютексы (помните - обработчики представлений, скорее всего, работают в многопоточном контексте, в зависимости от настройки вашего HTML-сервера).

Для подхода, основанного на SocketIO, все должно быть сделано немного иначе, но я не знаю достаточно об их модели процессов/потоков, чтобы сказать, что здесь лучше. Приведенная выше схема также довольно тяжела для ресурсов ядра (множество переключений контекста из-за изолированного select & вызовы чтения/записи для каждого отдельного терминала), так что есть много возможностей для улучшения, например, не использовать long-polling в первую очередь, а использовать websockets для лучшей общей производительности.

Вернуться на верх