Files

238 lines
9.7 KiB
Python

"""Exercise bounded worker pipes with real Python-only child processes.
The worker command is replaced at the Popen boundary. No native backend or
display is instantiated; request/permission/deadline behavior uses real pipes.
"""
from contextlib import contextmanager
import json
import os
from pathlib import Path
import subprocess
import sys
import time
import unittest
from unittest.mock import Mock, patch
try:
from framebridge_expanded import x11_text as subject
except ModuleNotFoundError as error:
if error.name != "framebridge_expanded":
raise
import x11_text as subject
STUB = r'''
import json, signal, sys, time
mode = sys.argv[1]
if mode == "hang_unread":
time.sleep(10)
raise SystemExit(2)
request = json.loads(sys.stdin.buffer.readline())
if request["window"] != 101 or request["geometry"] != [1920,1080]:
print("ERROR TYPE_FIXTURE_CONTEXT_INVALID", flush=True)
raise SystemExit(2)
if mode == "preflight":
print("ERROR TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP", flush=True)
elif mode == "exit":
raise SystemExit(2)
elif mode == "bad":
print("NOT A VALID MESSAGE", flush=True)
elif mode == "oversize":
print("x" * 5000, flush=True)
elif mode == "hang_before":
time.sleep(10)
else:
for index in range(2 if mode == "twice" else 1):
print("INPUT", flush=True)
if sys.stdin.buffer.readline() != b"GO\n":
raise SystemExit(3)
if mode == "hang_after":
time.sleep(10)
elif mode == "ignore_terminate":
signal.signal(signal.SIGTERM, signal.SIG_IGN)
time.sleep(10)
elif mode == "error_after":
print("ERROR TYPE_KEYMAP_CHANGED", flush=True)
else:
print("OK", flush=True)
'''
class BoundedWorkerTests(unittest.TestCase):
def test_selector_failure_does_not_spawn_worker(self):
with patch.object(subject.selectors, "DefaultSelector", side_effect=OSError("fixture")), \
patch.object(subject.subprocess, "Popen") as spawn:
with self.assertRaises(OSError):
subject.bounded_type_text("/", expected_window=101, expected_geometry=(1920, 1080),
before_input=lambda: None)
spawn.assert_not_called()
def test_spawn_failure_closes_selector(self):
selector = Mock()
with patch.object(subject.selectors, "DefaultSelector", return_value=selector), \
patch.object(subject.subprocess, "Popen", side_effect=OSError("fixture")):
with self.assertRaises(OSError):
subject.bounded_type_text("/", expected_window=101, expected_geometry=(1920, 1080),
before_input=lambda: None)
selector.close.assert_called_once_with()
@contextmanager
def child(self, mode):
real_popen = subprocess.Popen
children = []
def spawn(argv, **kwargs):
self.assertEqual(argv, [sys.executable, "-I", "-S", "-B", str(Path(subject.__file__).resolve()), "--worker"])
child = real_popen([sys.executable, "-u", "-c", STUB, mode], **kwargs)
children.append(child)
return child
try:
with patch.object(subject.subprocess, "Popen", side_effect=spawn):
yield children
finally:
for process in children:
if process.poll() is None:
process.kill()
process.wait(timeout=2)
self.assertIsNotNone(process.returncode)
self.assertTrue(process.stdin.closed)
self.assertTrue(process.stdout.closed)
def invoke(self, guard, *, text="-S /run/fixture", timeout=.5):
return subject.bounded_type_text(text, expected_window=101,
expected_geometry=(1920, 1080),
before_input=guard, timeout=timeout)
def test_preflight_failure_does_not_request_any_permission(self):
guard = Mock()
with self.child("preflight"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP$"):
self.invoke(guard)
guard.assert_not_called()
def test_one_input_batch_requires_one_guard_and_ack(self):
guard = Mock()
with self.child("success"):
self.assertIsNone(self.invoke(guard))
guard.assert_called_once_with()
def test_each_input_batch_requires_a_fresh_guard(self):
guard = Mock()
with self.child("twice"):
self.invoke(guard)
self.assertEqual(guard.call_count, 2)
def test_timeout_before_permission_has_zero_guard_calls(self):
guard = Mock()
started = time.monotonic()
with self.child("hang_before"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
self.invoke(guard, timeout=.15)
self.assertLess(time.monotonic() - started, 2)
guard.assert_not_called()
def test_timeout_after_permission_retains_possible_input_evidence(self):
guard = Mock()
started = time.monotonic()
with self.child("hang_after"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
self.invoke(guard, timeout=.15)
self.assertLess(time.monotonic() - started, 2)
guard.assert_called_once_with()
def test_guard_failure_terminates_child_waiting_for_ack(self):
guard = Mock(side_effect=RuntimeError("AUTHORITY_EXPIRED"))
started = time.monotonic()
with self.child("success"):
with self.assertRaisesRegex(RuntimeError, "^AUTHORITY_EXPIRED$"):
self.invoke(guard)
self.assertLess(time.monotonic() - started, 2)
guard.assert_called_once_with()
def test_stalled_child_that_ignores_termination_is_killed(self):
guard = Mock()
started = time.monotonic()
with self.child("ignore_terminate") as children:
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
self.invoke(guard, timeout=.15)
self.assertLess(time.monotonic() - started, 2)
guard.assert_called_once_with()
self.assertLess(children[0].returncode, 0)
def test_partial_input_error_preserves_permission_evidence(self):
guard = Mock()
with self.child("error_after"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEYMAP_CHANGED$"):
self.invoke(guard)
guard.assert_called_once_with()
def test_unknown_protocol_line_fails_without_permission(self):
guard = Mock()
with self.child("bad"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_PROTOCOL_INVALID$"):
self.invoke(guard)
guard.assert_not_called()
def test_oversized_worker_line_fails_without_permission(self):
guard = Mock()
with self.child("oversize"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_PROTOCOL_INVALID$"):
self.invoke(guard)
guard.assert_not_called()
def test_worker_exit_is_not_mistaken_for_success(self):
guard = Mock()
with self.child("exit"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_EXIT_UNCERTAIN$"):
self.invoke(guard)
guard.assert_not_called()
def test_large_unicode_request_cannot_block_on_unread_pipe(self):
guard = Mock()
started = time.monotonic()
with self.child("hang_unread"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
self.invoke(guard, text="\U0001f642" * 4096, timeout=.15)
self.assertLess(time.monotonic() - started, 2)
guard.assert_not_called()
def test_main_dispatch_runs_only_after_backend_definition(self):
# Replace only the NativeBackend AST in memory. This executes the real
# module/worker order with a constructor that cannot call native code.
wrapper = r'''
import ast, pathlib, sys
source = pathlib.Path(sys.argv[1]).read_text()
tree = ast.parse(source)
replacement = ast.parse("class NativeBackend:\n def __init__(self):\n raise TextError('TYPE_FIXTURE_NATIVE_BOUNDARY')\n").body[0]
for index, node in enumerate(tree.body):
if isinstance(node, ast.ClassDef) and node.name == 'NativeBackend':
tree.body[index] = replacement
ast.fix_missing_locations(tree)
sys.argv = [sys.argv[1], '--worker']
namespace = {'__name__': '__main__', '__file__': sys.argv[0]}
exec(compile(tree, sys.argv[0], 'exec'), namespace)
'''
request = json.dumps({"text": "a", "window": 101,
"geometry": [1920, 1080], "timeout": 1}) + "\n"
result = subprocess.run([sys.executable, "-c", wrapper, str(Path(subject.__file__))],
input=request, text=True, capture_output=True, timeout=2)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "ERROR TYPE_FIXTURE_NATIVE_BOUNDARY\n")
def test_real_isolated_worker_bootstrap_reaches_safe_display_preflight(self):
guard = Mock()
# This display number cannot refer to the user's real session. Native
# preflight can read libraries but cannot emit a GUI event.
with patch.dict(os.environ, {"DISPLAY": ":99999"}):
with self.assertRaises(subject.TextError) as caught:
self.invoke(guard, timeout=1)
self.assertIn(str(caught.exception), {"TYPE_DISPLAY_UNAVAILABLE",
"TYPE_NATIVE_LIBRARY_UNAVAILABLE",
"TYPE_NATIVE_ABI_UNSUPPORTED"})
guard.assert_not_called()
if __name__ == "__main__":
unittest.main()