From 504d86faa386d43671081fd21837973f35498432 Mon Sep 17 00:00:00 2001 From: catborise Date: Mon, 26 Oct 2026 18:50:26 +0300 Subject: [PATCH] fix(console): a serial console worker holds only its own terminal pty.fork returns an inheritable master descriptor, so each new console worker, and the ssh it may start, held the terminals of every other open session until it ended. A worker now closes every descriptor above its own terminal before it starts. --- console/socketiod | 3 +++ console/tests/test_socketiod.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/console/socketiod b/console/socketiod index b99d0dc3..e71cbdd5 100755 --- a/console/socketiod +++ b/console/socketiod @@ -52,6 +52,9 @@ def connect(sid, environ, auth): pid, fd = pty.fork() if pid == 0: try: + # pty.fork's master is inheritable: without this each worker would + # hold the other sessions' consoles open + os.closerange(3, os.sysconf("SC_OPEN_MAX")) os.execv(sys.executable, [sys.executable, CONSOLECALLBACK, str(instance.pk)]) finally: os._exit(1) diff --git a/console/tests/test_socketiod.py b/console/tests/test_socketiod.py index bb03e2d4..d2b15cfd 100644 --- a/console/tests/test_socketiod.py +++ b/console/tests/test_socketiod.py @@ -1,6 +1,7 @@ """socketiod: who may connect, and each session's console kept to itself.""" import os +import tempfile import time from importlib.machinery import SourceFileLoader from importlib.util import module_from_spec, spec_from_loader @@ -129,6 +130,30 @@ class SocketiodTestCase(Base): self.d.os.kill.assert_called_once_with(pid, self.d.signal.SIGKILL) +class WorkerIsolationTestCase(Base): + """A real fork: a session's worker holds no other session's console.""" + + @override_settings(SERIAL_CONSOLE_ENABLED=True) + def test_a_worker_has_only_its_own_terminal(self): + d = load_socketiod() + other = Instance.objects.create(compute=self.compute, name="vm-b", uuid="11111111-0000-0000-0000-000000000002") + UserInstance.objects.create(user=self.user, instance=other) + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as script: + # stands in for consolecallback: prints the descriptors it holds + script.write("import os, time\nprint(sorted(os.listdir('/proc/self/fd')))\ntime.sleep(5)\n") + self.addCleanup(os.unlink, script.name) + with patch.object(d, "CONSOLECALLBACK", script.name), patch.object(d.sio, "start_background_task"), \ + patch.object(d.db, "close_old_connections"): + d.connect("first", {}, {"token": self.token()}) + d.connect("second", {}, {"token": self.token(vm=other)}) + self.addCleanup(lambda: [d.stop(sid) for sid in list(d.sessions)]) + output = b"" + while b"]" not in output: + output += os.read(d.sessions["second"]["fd"], 1024) + # 0-2: its terminal, 3: the listing itself + self.assertEqual(output.decode().strip(), "['0', '1', '2', '3']") + + def _open(fd): try: os.fstat(fd)