Fix guarded X11 text mapping without group switching
This commit is contained in:
1 parent
7a27f7fd55
commit
b283999f5a
7 files changed
+1857
-4
No files matched your search
@@ -0,0 +1,237 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user