Expand shared Sprinter MCP with managed launch, raw reads and key input

This commit is contained in:
Александр Петров
2026-09-17 22:57:27 +03:00
parent a43e7bda89
commit 5dc7998324
22 changed files with 1120 additions and 67 deletions
+4 -1
View File
@@ -60,7 +60,10 @@ def main():
raise RuntimeError('DAP завершился до авторского лога')
if event.get('event') == 'output': outputs.append(event)
socket = launched['body']['socket']
tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6)
identity = rpc_call(socket, 'status')
tail = rpc_call(socket, 'mame_console_tail', {'count': 50}, timeout=6,
session_id=identity['session_id'],
build_id=identity['build_id'])
if not any('total=1' in line for line in tail['lines']):
raise RuntimeError('В debugger console MAME нет total=1: '+str(tail))
print(json.dumps({'event': 'macro_dual_console_verified',
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""MCP сам запускает hello, затем DAP подключается к тому же MAME."""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
from mcp import Client, StdioServerParameters
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(Path(__file__).resolve().parent))
from run_vscode_dap_probe import send, wait_response # noqa: E402
async def probe() -> None:
home = Path(os.environ['MAME_HOME']).resolve()
binary = Path(os.environ.get('MAME_BIN', home / 'sprinter')).resolve()
package = ROOT / 'tests/hello/.sprinter-cc-hello'
with tempfile.TemporaryDirectory(prefix='sprinter-managed-mcp-', dir='/tmp') as temp:
socket = str(Path(temp) / 'session.sock')
parameters = StdioServerParameters(
command=sys.executable,
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--build', str(package),
'--socket', socket, '--mame-home', str(home),
'--mame-bin', str(binary)])
async with Client(parameters) as client:
async def call(name: str, arguments: dict | None = None) -> dict:
result = await client.call_tool(name, arguments or {})
if result.is_error or result.structured_content is None:
raise RuntimeError(name + ': ' + repr(result.content))
return result.structured_content
names = {tool.name for tool in (await client.list_tools()).tools}
if not {'start_session', 'stop_session', 'where', 'read_variable',
'press_key'} <= names:
raise RuntimeError('Нет инструментов автономного запуска')
initial = await call('session_status')
if initial['phase'] != 'idle':
raise RuntimeError('Ожидался idle: ' + repr(initial))
started_at = time.monotonic()
accepted = await call('start_session')
start_ms = round((time.monotonic() - started_at) * 1000, 1)
if not accepted['accepted'] or start_ms > 3000:
raise RuntimeError('MCP start_session не ответил быстро: ' + repr(accepted))
duplicate = await client.call_tool('start_session', {})
if not duplicate.is_error or 'уже запущена' not in str(duplicate.content):
raise RuntimeError('Повторный start_session не был отклонён')
mame_pid = None
try:
deadline = time.monotonic() + 85
while time.monotonic() < deadline:
status = await call('session_status')
if status['phase'] == 'ready':
break
if status['phase'] in ('failed', 'stopped'):
raise RuntimeError('Launcher: ' + repr(status))
await asyncio.sleep(.2)
else:
raise TimeoutError('MCP session не дошла до main')
mame_pid = status['mame_pid']
location = await call('where')
if location.get('function', {}).get('name') != 'main':
raise RuntimeError('MCP не остановился в main: ' + repr(location))
await call('read_variable', {'name': 'errno'})
shot = await call('screenshot')
if Path(shot['path']).read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
raise RuntimeError('MCP не создал PNG')
dap = subprocess.Popen([sys.executable, str(ROOT / 'toolchain/sdbg_dap.py')],
cwd=ROOT, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
buffer = b''
try:
send(dap, 1, 'initialize')
_, buffer, _ = wait_response(dap, buffer, 'initialize', 10)
send(dap, 2, 'attach', {'socket': socket})
_, buffer, _ = wait_response(dap, buffer, 'attach', 10)
send(dap, 3, 'configurationDone')
_, buffer, _ = wait_response(dap, buffer, 'configurationDone', 10)
send(dap, 4, 'stackTrace')
frame, buffer, _ = wait_response(dap, buffer, 'stackTrace', 10)
if frame['body']['stackFrames'][0]['name'] != 'main':
raise RuntimeError('DAP attach не увидел main')
send(dap, 5, 'disconnect')
wait_response(dap, buffer, 'disconnect', 10)
finally:
if dap.poll() is None:
dap.terminate()
dap.communicate(timeout=8)
after_dap = await call('session_status')
if after_dap['phase'] != 'ready' or after_dap['session_id'] != status['session_id']:
raise RuntimeError('DAP disconnect завершил MCP-owned MAME')
source = str(ROOT / 'tests/hello/hello.c')
before = await call('set_line_breakpoint', {'file': source, 'line': 62})
await call('continue_execution')
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
at_getchar = await call('session_status')
if not at_getchar['running']:
break
await asyncio.sleep(.1)
else:
raise TimeoutError('MCP не остановился перед getchar')
location = await call('where')
if not any(item['line'] == 62 for item in location['sources']):
raise RuntimeError('MCP остановился не перед getchar: ' + repr(location))
await call('clear_breakpoint', {'identifier': before['id']})
after = await call('set_line_breakpoint', {'file': source, 'line': 63})
await call('continue_execution')
await asyncio.sleep(.4)
key = await call('press_key', {'key': 'x', 'frames': 3})
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
after_key = await call('session_status')
if not after_key['running']:
break
await asyncio.sleep(.1)
else:
raise TimeoutError('MCP press_key не завершил getchar')
location = await call('where')
if not any(item['line'] == 63 for item in location['sources']):
raise RuntimeError('После клавиши ожидалась строка 63: ' + repr(location))
await call('clear_breakpoint', {'identifier': after['id']})
print(json.dumps({'event': 'mcp_launch_dap_attach_verified',
'tools': len(names), 'start_ms': start_ms,
'session_id': status['session_id'],
'socket': socket, 'pc': location['pc'],
'key': key['key'], 'after_getchar_line': 63},
ensure_ascii=False), flush=True)
finally:
await call('stop_session')
deadline = time.monotonic() + 12
while time.monotonic() < deadline:
ended = await call('session_status')
if ended['phase'] == 'stopped':
break
await asyncio.sleep(.1)
else:
raise RuntimeError('stop_session не завершил launcher')
if Path(socket).exists():
raise RuntimeError('После stop_session остался Unix socket')
if mame_pid is not None:
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
try:
os.kill(mame_pid, 0)
except ProcessLookupError:
break
await asyncio.sleep(.1)
else:
raise RuntimeError('После stop_session остался процесс MAME')
if __name__ == '__main__':
asyncio.run(probe())
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Живой MCP 2.x пробник экрана выполняющегося hello во время getchar()."""
from __future__ import annotations
import argparse
import asyncio
import json
from pathlib import Path
import shutil
import sys
from mcp import Client, StdioServerParameters
ROOT = Path(__file__).resolve().parents[2]
async def probe(socket: str, proof: Path | None) -> None:
parameters = StdioServerParameters(
command=sys.executable,
args=[str(ROOT / 'toolchain/sdbg_mcp.py'), '--socket', socket])
async with Client(parameters) as client:
async def call(name: str, arguments: dict | None = None) -> dict:
result = await client.call_tool(name, arguments or {})
if result.is_error or result.structured_content is None:
raise RuntimeError(name + ': ' + repr(result.content))
return result.structured_content
status = await call('session_status')
if not status['running']:
raise RuntimeError('Ожидался выполняющийся hello в getchar()')
pixels = await call('read_screen_pixels', {
'x': 0, 'y': 0, 'width': 8, 'height': 8})
if pixels['stale_frame'] or len(pixels['hex']) != 256:
raise RuntimeError('Нет свежих пикселей выполняющегося hello')
shot = await call('screenshot')
source = Path(shot['path'])
if shot['stale_frame'] or source.read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
raise RuntimeError('Нет PNG выполняющегося hello')
if proof:
proof.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, proof)
print(json.dumps({'event': 'mcp_hello_running_screen',
'frame': shot['frame'], 'size': shot['size'],
'proof': str(proof) if proof else str(source)},
ensure_ascii=False), flush=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--socket', required=True)
parser.add_argument('--proof', type=Path)
args = parser.parse_args()
asyncio.run(probe(args.socket, args.proof))
if __name__ == '__main__':
main()
+33 -2
View File
@@ -22,7 +22,9 @@ async def probe(socket: str, source: str, foreign_id: int):
names = {tool.name for tool in listed.tools}
expected = {'session_status', 'where', 'read_registers', 'read_memory',
'recent_events', 'set_line_breakpoint', 'clear_breakpoint',
'claim_control', 'release_control'}
'claim_control', 'release_control', 'list_shares',
'read_share', 'read_vram', 'read_screen_pixels', 'screenshot',
'read_program_memory', 'list_ports'}
if not expected <= names:
raise RuntimeError('Не хватает MCP-инструментов: ' + str(expected - names))
@@ -44,6 +46,33 @@ async def probe(socket: str, source: str, foreign_id: int):
registers['registers']['PC'] != location['pc'] or \
len(memory['hex']) != 8 or not events['events']:
raise RuntimeError('Неполный C-снимок MCP: ' + repr((status, location, memory)))
program = await call('read_program_memory', {
'address': hex(0x10000 + location['pc']), 'length': 4})
if program['hex'] != memory['hex'] or program['space'] != 'program':
raise RuntimeError('Raw program и logical Z80 расходятся в main')
ports = await call('list_ports')
if ports['truncated'] or not any(':kbd:ms_naturl:' in item['tag']
for item in ports['ports']):
raise RuntimeError('Порты PC-клавиатуры не найдены: ' + repr(ports)[:500])
shares = await call('list_shares')
vram = [item for item in shares['shares'] if 'vram' in item['tag']]
if len(vram) != 1 or vram[0]['size'] < 4:
raise RuntimeError('VRAM share не найден: ' + repr(shares))
raw_vram = await call('read_vram', {'address': '0', 'length': 4})
same_share = await call('read_share', {
'tag': vram[0]['tag'], 'address': '0', 'length': 4})
if len(raw_vram['hex']) != 8 or raw_vram['hex'] != same_share['hex']:
raise RuntimeError('Чтение VRAM/share различается')
pixels = await call('read_screen_pixels', {
'x': 0, 'y': 0, 'width': 2, 'height': 2})
if len(pixels['hex']) != 16 or not pixels['stale_frame']:
raise RuntimeError('Неверный снимок пикселей: ' + repr(pixels))
shot = await call('screenshot')
snapshot = Path(shot['path'])
if snapshot.suffix != '.png' or snapshot.stat().st_size != shot['size'] or \
snapshot.read_bytes()[:8] != b'\x89PNG\r\n\x1a\n':
raise RuntimeError('Снимок MAME не является PNG: ' + repr(shot))
own = await call('set_line_breakpoint', {'file': source, 'line': 62})
foreign = await client.call_tool('clear_breakpoint', {'identifier': foreign_id})
@@ -59,7 +88,9 @@ async def probe(socket: str, source: str, foreign_id: int):
'build_id': status['build_id'], 'pc': location['pc'],
'tools': len(names), 'foreign_point_rejected': True,
'owned_point_for_cleanup': own['id'],
'control_claim_release': True},
'control_claim_release': True,
'shares_pixels_png': True,
'program_and_ports': True},
ensure_ascii=False), flush=True)
+40 -16
View File
@@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parents[2]
PYTHON = Path.home()/'.pyenv/shims/python'
sys.path.insert(0, str(ROOT/'toolchain'))
from mame_interactive import resolve
from sdbg.server import rpc_call
from sdbg.server import RPC_GENERATION_MUTATIONS, rpc_call as raw_rpc_call
from sdbg.session import SessionError
@@ -98,6 +98,10 @@ def main():
help='Python 3.12 с MCP SDK 2.x для совместной живой пробы DAP/MCP')
parser.add_argument('--orphan-expiry', action='store_true',
help='проверить очистку точки владельца MCP без heartbeat через 30 с')
parser.add_argument('--screen-mcp-python', default=None,
help='Python с MCP SDK 2.x для снимка hello во время getchar')
parser.add_argument('--screen-proof', default=None,
help='сохранить PNG работающего hello вне временной сессии')
parser.add_argument('--clear-while-running', action='store_true',
help='во время WAITKEY удалить личную точку; использовать с --waitkey --emulated-key')
parser.add_argument('--exit-while-stopped', action='store_true',
@@ -128,6 +132,14 @@ def main():
launch['launchPath'] = options.launch_path
send(process, 2, 'launch', launch)
launched, buffer, events = wait_response(process, buffer, 'launch', 80)
session_identity = raw_rpc_call(launched['body']['socket'], 'status')
def session_rpc(path, method, arguments=None, timeout=10):
identity = {'session_id': session_identity['session_id'],
'build_id': session_identity['build_id']}
if method in RPC_GENERATION_MUTATIONS:
current = raw_rpc_call(path, 'status', **identity)
identity['generation'] = current['generation']
return raw_rpc_call(path, method, arguments, timeout, **identity)
if not any(item.get('event') == 'initialized' for item in events):
initialized, buffer = receive(process, buffer, 5)
if initialized.get('event') != 'initialized':
@@ -156,13 +168,13 @@ def main():
raise RuntimeError('MCP SDK probe: '+probe.stderr[-4000:])
if options.orphan_expiry:
owner = 'mcp:orphan-expiry-probe'
orphan = rpc_call(launched['body']['socket'], 'break_line', {
orphan = session_rpc(launched['body']['socket'], 'break_line', {
'file': source, 'line': 62, 'owner': owner})
rpc_call(launched['body']['socket'], 'claim_control', {'owner': owner})
session_rpc(launched['body']['socket'], 'claim_control', {'owner': owner})
print('Ожидание истечения MCP owner и очистки его точки...', flush=True)
deadline = time.monotonic()+36
while time.monotonic() < deadline:
events = rpc_call(launched['body']['socket'], 'events', {'after': 0})
events = session_rpc(launched['body']['socket'], 'events', {'after': 0})
if any(event['event'] == 'owner_expired' and
event['body']['owner'] == owner for event in events['events']):
break
@@ -170,7 +182,7 @@ def main():
else:
raise RuntimeError('MCP owner не истёк за 36 с')
try:
rpc_call(launched['body']['socket'], 'clear_breakpoint', {
session_rpc(launched['body']['socket'], 'clear_breakpoint', {
'id': orphan['id'], 'owner': owner})
except SessionError as error:
if 'Неизвестная логическая точка' not in str(error):
@@ -284,37 +296,49 @@ def main():
socket_path = launched['body']['socket']
clear_point = None
if options.clear_while_running:
clear_point = rpc_call(socket_path, 'break_line', {
clear_point = session_rpc(socket_path, 'break_line', {
'file': str(ROOT/'tests/hello/hello.c'), 'line': 63,
'owner': 'probe:running-clear'})
started = time.monotonic()
send(process, 8, 'next')
_, buffer, _ = wait_response(process, buffer, 'next', 3)
response_ms = round((time.monotonic()-started)*1000, 2)
if options.screen_mcp_python:
time.sleep(.6)
screen_command = [options.screen_mcp_python,
str(ROOT/'tests/sdbg/run_mcp_screen_probe.py'),
'--socket', socket_path]
if options.screen_proof:
screen_command += ['--proof', options.screen_proof]
screen_probe = subprocess.run(screen_command, capture_output=True,
text=True, timeout=20, check=False)
if screen_probe.returncode:
raise RuntimeError('MCP screen probe: '+screen_probe.stderr[-3000:])
print(screen_probe.stdout.strip(), flush=True)
if clear_point:
snapshot = rpc_call(socket_path, 'snapshot', timeout=5)
snapshot = session_rpc(socket_path, 'snapshot', timeout=5)
if snapshot['state'] != 'running':
raise RuntimeError('CPU не выполняется перед очисткой точки')
try:
rpc_call(socket_path, 'read_memory', {'address': 0, 'length': 1})
session_rpc(socket_path, 'read_memory', {'address': 0, 'length': 1})
raise RuntimeError('Чтение памяти разрешено при running CPU')
except SessionError as error:
if 'остановленного CPU' not in str(error):
raise
rpc_call(socket_path, 'clear_breakpoint', {
session_rpc(socket_path, 'clear_breakpoint', {
'id': clear_point['id'], 'owner': 'probe:running-clear'})
if '--emulated-key' in sys.argv:
first = rpc_call(socket_path, 'snapshot', timeout=5)
first = session_rpc(socket_path, 'snapshot', timeout=5)
time.sleep(1)
second = rpc_call(socket_path, 'snapshot', timeout=5)
second = session_rpc(socket_path, 'snapshot', timeout=5)
if second['paused'] or second['time'] <= first['time']:
raise RuntimeError('MAME не обновляет input frames: '+
str((first, second)))
tag, mask, _ = resolve('x')
rpc_call(socket_path, 'input_key',
session_rpc(socket_path, 'input_key',
{'tag': tag, 'mask': mask, 'down': True}, timeout=5)
time.sleep(.15)
rpc_call(socket_path, 'input_key',
session_rpc(socket_path, 'input_key',
{'tag': tag, 'mask': mask, 'down': False}, timeout=5)
key_stop, buffer = wait_event(process, buffer, 'stopped', 10)
if not key_stop:
@@ -324,7 +348,7 @@ def main():
after = after_response['body']['stackFrames'][0]
if after['line'] != 63:
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
ascii_code = rpc_call(socket_path, 'registers', timeout=5)
ascii_code = session_rpc(socket_path, 'registers', timeout=5)
ascii_code = ascii_code['registers']['DE'] & 0xff
if ascii_code != ord('x'):
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
@@ -340,7 +364,7 @@ def main():
return 0
if '--manual-key' in sys.argv:
socket_path = launched['body']['socket']
keyboard_state = rpc_call(socket_path, 'snapshot', timeout=5)['keyboards']
keyboard_state = session_rpc(socket_path, 'snapshot', timeout=5)['keyboards']
if keyboard_state.get(':kbd:ms_naturl') is not True:
raise RuntimeError('Физическая PC-клавиатура MAME выключена: '+
str(keyboard_state))
@@ -357,7 +381,7 @@ def main():
after = after_response['body']['stackFrames'][0]
if after['line'] != 63:
raise RuntimeError('После клавиши ожидалась строка 63: '+str(after))
ascii_code = rpc_call(socket_path, 'registers', timeout=5)
ascii_code = session_rpc(socket_path, 'registers', timeout=5)
ascii_code = ascii_code['registers']['DE'] & 0xff
if ascii_code != ord('x'):
raise RuntimeError(f'WAITKEY вернул {ascii_code:#x}, ожидался x (0x78)')
+34 -5
View File
@@ -14,8 +14,11 @@ class McpAdapterTests(unittest.TestCase):
def setUp(self):
self.calls = []
def rpc(path, method, arguments, timeout=10):
self.calls.append((path, method, arguments, timeout))
def rpc(path, method, arguments, timeout=10, **identity):
self.calls.append((path, method, arguments, timeout, identity))
if method == 'status':
return {'session_id': 'test-session', 'build_id': 'test-build',
'generation': 7}
return {'method': method, 'arguments': arguments}
self.client = McpSession('/tmp/sprinter-test.sock', rpc=rpc)
@@ -26,9 +29,13 @@ class McpAdapterTests(unittest.TestCase):
self.client.clear_breakpoint(7)
self.client.clear_owned_breakpoints()
self.assertTrue(self.client.owner.startswith('mcp:'))
for _, _, arguments, _ in self.calls:
for _, method, arguments, _, identity in self.calls:
if method == 'status':
continue
self.assertEqual(arguments['owner'], self.client.owner)
self.assertEqual([item[1] for item in self.calls],
self.assertEqual(identity['session_id'], 'test-session')
self.assertEqual(identity['build_id'], 'test-build')
self.assertEqual([item[1] for item in self.calls if item[1] != 'status'],
['break_line', 'break_function', 'clear_breakpoint',
'clear_owned_breakpoints'])
@@ -36,7 +43,7 @@ class McpAdapterTests(unittest.TestCase):
self.client.read_memory('0xc000', 16)
self.assertEqual(self.calls[-1][2], {'address': 0xc000, 'length': 16})
self.client.events(after=4, timeout=12)
self.assertEqual(self.calls[-1][1:],
self.assertEqual(self.calls[-1][1:4],
('events', {'after': 4, 'timeout': 12,
'owner': self.client.owner}, 14))
before = len(self.calls)
@@ -48,6 +55,28 @@ class McpAdapterTests(unittest.TestCase):
self.client.step_source('back')
self.assertEqual(len(self.calls), before)
def test_press_key_releases_shift_after_snapshot_error(self):
calls = []
def rpc(path, method, arguments, timeout=10, **identity):
calls.append((method, arguments))
if method == 'status':
return {'session_id': 'test-session', 'build_id': 'test-build',
'generation': 7, 'running': True}
if method == 'snapshot':
raise SessionError('Потеряна связь с MAME')
return {}
client = McpSession('/tmp/sprinter-test.sock', rpc=rpc)
with self.assertRaisesRegex(SessionError, 'Потеряна связь'):
client.press_key('X')
keys = [args for method, args in calls if method == 'input_key']
self.assertEqual([item['down'] for item in keys],
[True, True, False, False])
self.assertEqual(keys[0]['tag'], keys[-1]['tag'])
self.assertEqual(keys[1]['tag'], keys[-2]['tag'])
self.assertTrue(all(item['owner'] == client.owner for item in keys))
if __name__ == '__main__':
unittest.main()
+35 -1
View File
@@ -22,6 +22,7 @@ class DummyBridge:
def __init__(self):
self.calls = []
self.state = 'stopped'
self.generation = 1
def close(self): pass
def request(self, command, **arguments):
self.calls.append((command, arguments))
@@ -189,8 +190,26 @@ class ServerTests(unittest.TestCase):
try:
status = rpc_call(server.path, 'status')
self.assertEqual(status['build_id'], 'test')
events = rpc_call(server.path, 'events', {'after': 0})
identity = {'session_id': status['session_id'],
'build_id': status['build_id']}
events = rpc_call(server.path, 'events', {'after': 0}, **identity)
self.assertEqual(events['events'][0]['event'], 'stopped')
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
rpc_call(server.path, 'events', {'after': 0})
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
rpc_call(server.path, 'events', {'after': 0},
session_id='old', build_id=status['build_id'])
with self.assertRaisesRegex(SessionError, 'Устаревшая RPC-сессия'):
rpc_call(server.path, 'events', {'after': 0},
session_id=status['session_id'], build_id='old-build')
with self.assertRaisesRegex(SessionError, 'Устаревшая generation'):
rpc_call(server.path, 'break_line',
{'file': '/src/main.c', 'line': 3},
generation=0, **identity)
point = rpc_call(server.path, 'break_line',
{'file': '/src/main.c', 'line': 3},
generation=status['generation'], **identity)
self.assertEqual(point['id'], 1)
with self.assertRaisesRegex(SessionError, 'Неизвестный'):
rpc_call(server.path, 'unknown')
finally:
@@ -290,6 +309,21 @@ class ServerTests(unittest.TestCase):
finally:
controller.close()
def test_screen_and_share_reads_reject_oversized_requests(self):
controller = SessionController(DummySession())
try:
with self.assertRaisesRegex(SessionError, 'длина 1..4096'):
controller.call('read_share', {'tag': ':vram', 'address': 0,
'length': 4097})
with self.assertRaisesRegex(SessionError, 'точный tag'):
controller.call('read_share', {'tag': '', 'address': 0,
'length': 1})
with self.assertRaisesRegex(SessionError, '8192 пикселей'):
controller.call('read_screen_pixels', {'x': 0, 'y': 0,
'width': 128, 'height': 128})
finally:
controller.close()
def test_idle_snapshot_detects_invalidation(self):
session = DummySession()
controller = SessionController(session)