diff --git a/JSON_RPC_API.md b/JSON_RPC_API.md index ee7c3de..30e485f 100644 --- a/JSON_RPC_API.md +++ b/JSON_RPC_API.md @@ -661,7 +661,8 @@ watchpoints stop after the access; resuming does not suppress the next access. No parameters. Requests that execution stop at the next instruction boundary. When the request returns, no further CPU instruction is executed until a continue -or step request. Result: +or step request. If already stopped, pause is read-only: it preserves the last stop, +operation results, revision and breakpoint resume context. Result: ```json {"paused":true,"general":{},"segments":{},"ip":0,"flags":0, diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index a6e79bc..c4ef850 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,3 +1,20 @@ +# Handoff - idempotent stopped pause, 2026-10-06 + +FINISHED local: execution.pause on a stopped machine preserves the complete +machine/control state, retained stop and breakpoint resume context. Continuing +still executes the stopped instruction once before the next breakpoint. Active +pause still cancels step/run_until and completes their operation without CPU +execution. New stopped-pause controls fail on the old handler. +FINISHED gates: 42 affected tests pass under CPython and PyPy; removing the +stopped-pause guard fails both new state-preservation controls, restored source +passes. Scoped review found no defects; paired old/new continuation confirms the +old handler retriggers the current breakpoint and the fixed handler reaches the +next breakpoint. PR73 epoch/API integrated at d9e0f159 after exact-head public CI. +Rebase onto that merged master is complete. WIP: final-head public CI and +pause integration. Parent pin stays fixed. + +Earlier handoff notes below describe their historical slices. + # Handoff - keyboard input epochs, 2026-10-06 FINISHED local: each validated input.keyboard/keyboard.scancode batch advances diff --git a/main.py b/main.py index ac70df1..242eef6 100755 --- a/main.py +++ b/main.py @@ -953,6 +953,8 @@ def handle_debug(request): } if method == 'execution.pause': + if control['paused']: + return {'paused': True, **rpc_registers()} rpc_clear_run_until() control['paused'] = True control['step'] = False diff --git a/tests/test_execution_rpc.py b/tests/test_execution_rpc.py index fdd40f9..4dda13b 100644 --- a/tests/test_execution_rpc.py +++ b/tests/test_execution_rpc.py @@ -5,6 +5,7 @@ The original while-loop body is executed once per pump in a one-item loop. """ import ast +import copy import base64 import hashlib import json @@ -84,6 +85,47 @@ def interrupt_bp(self): class ExecutionRPCTests(unittest.TestCase): + def test_redundant_pause_preserves_stop_and_execution_breakpoint_resume(self): + machine = HeadlessMachine() + machine.load(b'\x90\x90') + first = machine.execution_bp(0x100) + next_breakpoint = machine.execution_bp(0x101) + operation = machine.rpc('execution.continue') + machine.pump() + self.assertEqual(machine.control['last_stop']['breakpoint_id'], first) + stopped = copy.deepcopy(machine.control) + registers = machine.rpc('state.get_registers') + for _ in range(2): + self.assertEqual(machine.rpc('execution.pause'), {'paused': True, **registers}) + self.assertEqual(machine.control, stopped) + self.assertEqual(machine.rpc('execution.wait', operation_id=operation['operation_id'])['stop_reason'], + stopped['last_stop']) + machine.rpc('execution.continue') + machine.pump() + self.assertEqual(machine.state.GetIP(), 0x101) + machine.pump() + self.assertEqual(machine.control['last_stop']['breakpoint_id'], next_breakpoint) + + def test_active_pause_cancels_step_and_run_until_without_executing(self): + for method, params in (('execution.step', {}), ('execution.run_until', {'predicate': { + 'address': {'space': 'segmented', 'segment': 0x1000, 'offset': 0x102}}})): + with self.subTest(method=method): + machine = HeadlessMachine() + machine.load(b'\x90\x90\x90') + before = machine.rpc('state.get_registers') + operation = machine.rpc(method, **params) + machine.rpc('execution.pause') + self.assertEqual(machine.rpc('state.get_registers'), before) + self.assertTrue(machine.control['paused']) + self.assertFalse(machine.control['step']) + self.assertIsNone(machine.control['run_until_id']) + self.assertIsNone(machine.control['skip_breakpoint_id']) + self.assertIsNone(machine.control['active_operation']) + done = machine.rpc('execution.wait', operation_id=operation['operation_id']) + self.assertEqual(done['stop_reason']['kind'], 'pause') + self.assertEqual(done['stop_reason']['registers'], before) + self.assertEqual(machine.rpc('breakpoints.list')['breakpoints'], []) + def test_running_keyboard_enqueue_advances_epoch_without_completing_operation(self): machine = HeadlessMachine() machine.namespace['kb'] = keyboard.Keyboard() diff --git a/tests/test_snapshot_contract.py b/tests/test_snapshot_contract.py index ed1e9c2..0bb64f3 100644 --- a/tests/test_snapshot_contract.py +++ b/tests/test_snapshot_contract.py @@ -17,6 +17,20 @@ class SnapshotContractTests(unittest.TestCase): + def test_redundant_pause_preserves_complete_machine_and_control(self): + state = self.cpu.GetState() + breakpoint = self.h.rpc('breakpoints.create', address={ + 'space': 'segmented', 'segment': state.GetCS(), 'offset': state.GetIP()})['breakpoint_id'] + self.h.rpc('execution.continue') + self.h.pump() + self.assertEqual(self.h.control['last_stop']['breakpoint_id'], breakpoint) + before = capture_machine(self.cpu) + controls = copy.deepcopy(self.h.control) + for _ in range(2): + self.h.rpc('execution.pause') + self.assertEqual(capture_machine(self.cpu), before) + self.assertEqual(self.h.control, controls) + def test_keyboard_epochs_guard_complete_snapshot_state_and_rejected_batches(self): self.h.namespace['kb'] = self.cpu._devices[1] old = self.export()