mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
When the serial console process exited on its own (a bad token, an unreachable host, a killed consolecallback), the read task died on EIO while fd and child_pid stayed set: the child was a zombie and every later connection wrote to the dead pty until socketiod was restarted. A child that failed before exec also returned into the server code and sent its traceback to the client. Now the read loop treats EOF/EIO as the end: it reaps the child, closes the pty, tells the clients "[console closed]" and disconnects them, so the next connection starts a new console. The child always leaves with os._exit, also without a token; output is decoded incrementally, and the unused imports are gone.
118 lines
4.1 KiB
Python
118 lines
4.1 KiB
Python
"""socketiod's console process ending on its own: the clients are told and
|
|
disconnected, the process is reaped and the next connection starts a new one."""
|
|
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import socketio
|
|
except ImportError: # pragma: no cover
|
|
socketio = None
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SOCKETIOD = ROOT / "console" / "socketiod"
|
|
|
|
|
|
def free_port():
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def children(pid):
|
|
"""(pid, state) of the processes whose parent is pid."""
|
|
found = []
|
|
for entry in os.listdir("/proc"):
|
|
try:
|
|
with open("/proc/%s/stat" % entry) as f:
|
|
fields = f.read().rsplit(")", 1)[1].split()
|
|
except (OSError, IndexError):
|
|
continue
|
|
if int(fields[1]) == pid:
|
|
found.append((int(entry), fields[0]))
|
|
return found
|
|
|
|
|
|
@unittest.skipIf(socketio is None, "python-socketio is not installed")
|
|
@unittest.skipUnless(sys.platform.startswith("linux"), "reads /proc")
|
|
class SocketiodChildExitTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
(Path(tmp.name) / "socketiod_child_settings.py").write_text(
|
|
"from webvirtcloud.settings import * # noqa\n"
|
|
"SERIAL_CONSOLE_ENABLED = True\n"
|
|
"DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': %r}}\n"
|
|
% str(Path(tmp.name) / "db.sqlite3")
|
|
)
|
|
self.port = free_port()
|
|
env = dict(
|
|
os.environ,
|
|
DJANGO_SETTINGS_MODULE="socketiod_child_settings",
|
|
PYTHONPATH=os.pathsep.join([tmp.name, str(ROOT)]),
|
|
)
|
|
self.server = subprocess.Popen(
|
|
[sys.executable, str(SOCKETIOD), "-H", "127.0.0.1", "-p", str(self.port)],
|
|
cwd=ROOT,
|
|
env=env,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
self.addCleanup(self.stop_server)
|
|
end = time.monotonic() + 20
|
|
while time.monotonic() < end:
|
|
with socket.socket() as s:
|
|
if s.connect_ex(("127.0.0.1", self.port)) == 0:
|
|
return
|
|
self.assertIsNone(self.server.poll(), "socketiod exited at start")
|
|
time.sleep(0.1)
|
|
self.fail("socketiod did not listen within 20 s")
|
|
|
|
def stop_server(self):
|
|
self.server.kill()
|
|
self.server.wait()
|
|
|
|
def open_console(self):
|
|
"""Connect with a malformed token, so the console process fails at once;
|
|
returns the output and whether the server disconnected the client."""
|
|
client = socketio.Client(reconnection=False)
|
|
output = []
|
|
gone = threading.Event()
|
|
client.on("pty_output", lambda data: output.append(data["output"]))
|
|
client.on("disconnect", lambda *args: gone.set())
|
|
client.connect(
|
|
"http://127.0.0.1:%d" % self.port,
|
|
headers={"Cookie": "token=no-such-vm"},
|
|
transports=["polling"],
|
|
)
|
|
try:
|
|
gone.wait(15)
|
|
finally:
|
|
if not gone.is_set():
|
|
client.disconnect()
|
|
return "".join(output), gone.is_set()
|
|
|
|
def assert_no_child_left(self):
|
|
end = time.monotonic() + 5
|
|
while children(self.server.pid) and time.monotonic() < end:
|
|
time.sleep(0.05)
|
|
self.assertEqual(children(self.server.pid), [])
|
|
|
|
def test_the_client_is_told_and_the_next_connection_starts_again(self):
|
|
for attempt in (1, 2):
|
|
with self.subTest(attempt=attempt):
|
|
output, disconnected = self.open_console()
|
|
self.assertIn("console closed", output)
|
|
# no traceback of socketiod's own code (on Python 3.13 eventlet
|
|
# prints an ignored _after_fork error in every forked child)
|
|
self.assertNotIn('socketiod", line', output)
|
|
self.assertTrue(disconnected)
|
|
self.assert_no_child_left()
|
|
self.assertIsNone(self.server.poll())
|