diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index fdd1c7d..177f66b 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,6 +1,6 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-02-19 (FU-P13-T13-FU-1_Set__stopped_event_and__stop_event_in__rollback_startup_for_defensive_consistency) +**Last Updated:** 2026-02-20 (P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport) ## Archived Tasks @@ -130,6 +130,7 @@ | FU-P13-T14 | [FU-P13-T14_Complete_interactive_Xcode_prompt_verification_and_close_P13-T5/](FU-P13-T14_Complete_interactive_Xcode_prompt_verification_and_close_P13-T5/) | 2026-02-19 | FAIL | | FU-P13-T15 | [FU-P13-T15_Restore_broker_same-UID_client_acceptance_when_peer_credential_APIs_are_unavailable/](FU-P13-T15_Restore_broker_same-UID_client_acceptance_when_peer_credential_APIs_are_unavailable/) | 2026-02-19 | PASS | | FU-P13-T13-FU-1 | [FU-P13-T13-FU-1_Set__stopped_event_and__stop_event_in__rollback_startup_for_defensive_consistency/](FU-P13-T13-FU-1_Set__stopped_event_and__stop_event_in__rollback_startup_for_defensive_consistency/) | 2026-02-19 | PASS | +| P14-T1 | [P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport/](P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport/) | 2026-02-20 | PASS | ## Historical Artifacts @@ -225,6 +226,7 @@ | [REVIEW_FU-P13-T14_prompt_validation_closeout.md](_Historical/REVIEW_FU-P13-T14_prompt_validation_closeout.md) | Review report for FU-P13-T14 | | [REVIEW_FU-P13-T15_peer_credential_fallback.md](_Historical/REVIEW_FU-P13-T15_peer_credential_fallback.md) | Review report for FU-P13-T15 | | [REVIEW_FU-P13-T13-FU-1_rollback_event_consistency.md](_Historical/REVIEW_FU-P13-T13-FU-1_rollback_event_consistency.md) | Review report for FU-P13-T13-FU-1 | +| [REVIEW_P14-T1_broker_alias_map_bounds.md](_Historical/REVIEW_P14-T1_broker_alias_map_bounds.md) | Review report for P14-T1 | ## Archive Log @@ -405,3 +407,5 @@ | 2026-02-19 | FU-P13-T15 | Archived REVIEW_FU-P13-T15_peer_credential_fallback report | | 2026-02-19 | FU-P13-T13-FU-1 | Archived Set__stopped_event_and__stop_event_in__rollback_startup_for_defensive_consistency (PASS) | | 2026-02-19 | FU-P13-T13-FU-1 | Archived REVIEW_FU-P13-T13-FU-1_rollback_event_consistency report | +| 2026-02-20 | P14-T1 | Archived Bound_per_session_ID_restore_maps_in_broker_transport (PASS) | +| 2026-02-20 | P14-T1 | Archived REVIEW_P14-T1_broker_alias_map_bounds report | diff --git a/SPECS/ARCHIVE/P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport/P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport.md b/SPECS/ARCHIVE/P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport/P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport.md new file mode 100644 index 0000000..d7e027d --- /dev/null +++ b/SPECS/ARCHIVE/P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport/P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport.md @@ -0,0 +1,84 @@ +# PRD: P14-T1 — Bound per-session ID restore maps in broker transport + +**Status:** INPROGRESS +**Priority:** P1 +**Phase:** Phase 14 — Release 0.4.0 Readiness +**Dependencies:** FU-P13-T11 (✅), FU-P13-T15 (✅) + +--- + +## 1. Objective + +Prevent unbounded growth of per-session ID alias/restore structures in broker +transport while preserving JSON-RPC ID round-trip fidelity for both integer and +string request IDs. + +--- + +## 2. Problem Summary + +`ClientSession` currently keeps three mapping structures: +- `id_restore` (`local_alias -> original_id`) +- `string_id_map` (`original_string_id -> local_alias`) +- `int_id_map` (`original_int_id -> local_alias`) + +These entries are added when requests are remapped but are not removed after a +response is routed or pending requests are drained. In long-lived broker +sessions this causes steady memory growth. + +Additionally, local alias allocation wraps at 20-bit bounds without checking for +active aliases, so a wrapped allocation can collide with still-active mappings. + +--- + +## 3. Design + +### 3.1 Lifecycle cleanup for completed requests + +Add explicit alias-release logic that: +- Removes `local_alias` from `id_restore`. +- Removes the corresponding entry from `string_id_map` / `int_id_map` only if + that map still points to the same alias. + +Invoke this cleanup when: +- Upstream responses are routed successfully. +- Pending requests are drained during shutdown. +- A request fails before reaching upstream (upstream unavailable or write + failure). + +### 3.2 Safe wrap behavior for local ID allocation + +Update local alias allocator to skip aliases currently present in `id_restore`. +If all aliases are exhausted, raise a deterministic error and return `-32001` +to the client instead of reusing an active alias. + +### 3.3 Behavior guarantees + +- ID round-trip restoration remains exact for int and string IDs. +- Map size is bounded by active in-flight requests rather than historical + traffic volume. +- Wrap-around never overwrites active alias mappings. + +--- + +## 4. Files To Change + +| File | Change | +|------|--------| +| `src/mcpbridge_wrapper/broker/transport.py` | Add alias-release helpers, wrap-safe allocator, and cleanup calls on response/drain/error paths | +| `tests/unit/test_broker_transport.py` | Add regression tests for map pruning, bounded growth, and wrap-safe alias allocation | +| `SPECS/INPROGRESS/P14-T1_Validation_Report.md` | Capture gate results and acceptance evidence | + +--- + +## 5. Acceptance Criteria + +- [ ] Per-session restore/alias maps do not grow unbounded for completed requests. +- [ ] Existing ID round-trip fidelity guarantees remain intact for int and string IDs. +- [ ] Tests cover wrap/prune behavior and pass in CI. +- [ ] Quality gates are executed and documented. + + +--- +**Archived:** 2026-02-20 +**Verdict:** PASS diff --git a/SPECS/ARCHIVE/P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport/P14-T1_Validation_Report.md b/SPECS/ARCHIVE/P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport/P14-T1_Validation_Report.md new file mode 100644 index 0000000..58b3780 --- /dev/null +++ b/SPECS/ARCHIVE/P14-T1_Bound_per_session_ID_restore_maps_in_broker_transport/P14-T1_Validation_Report.md @@ -0,0 +1,56 @@ +# Validation Report: P14-T1 — Bound per-session ID restore maps in broker transport + +**Date:** 2026-02-20 +**Verdict:** PASS + +--- + +## Acceptance Criteria + +| # | Criterion | Status | +|---|-----------|--------| +| 1 | Per-session restore/alias maps do not grow unbounded for completed requests | ✅ PASS | +| 2 | Existing ID round-trip fidelity guarantees remain intact for int and string IDs | ✅ PASS | +| 3 | Tests cover wrap/prune behavior and pass in CI | ✅ PASS | +| 4 | Quality gates are executed and documented | ✅ PASS | + +--- + +## Evidence + +### Functional behavior + +- Implemented alias lifecycle cleanup for all completion paths: + - Upstream response routing + - Upstream unavailable/write-failure rollback + - Session drain during shutdown +- Local ID allocator now skips active aliases after wrap and raises a deterministic error if alias space is exhausted. + +### New/updated regression coverage + +- `tests/unit/test_broker_transport.py::TestP14T1MapBounding::test_maps_remain_bounded_for_completed_request_stream` +- `tests/unit/test_broker_transport.py::TestP14T1MapBounding::test_alloc_local_id_skips_active_aliases_after_wrap` +- `tests/unit/test_broker_transport.py::TestProcessClientLineAdditional::test_string_id_mapping_is_released_after_response` +- `tests/unit/test_broker_transport.py::TestIntegerIDFidelity::test_integer_id_mapping_is_released_after_response` +- Additional assertions added for cleanup on write-failure, route, and drain paths. + +### Command results + +- `pytest tests/unit/test_broker_transport.py -k 'not SocketPermissions' -q` → **47 passed, 1 deselected** +- `pytest tests/unit/test_broker_transport.py -k 'P14T1MapBounding or mapping_is_released' -q` → **4 passed** +- `ruff check src/` → **All checks passed** +- `mypy src/` → **Success: no issues found in 18 source files** +- `pytest` → **1 failed, 625 passed, 5 skipped** +- `pytest --cov` → **1 failed, 625 passed, 5 skipped; coverage 91.33% (>=90%)** + +The single failing test in full-suite runs is environment-specific and pre-existing in this workspace: +- `tests/unit/test_broker_transport.py::TestSocketPermissions::test_socket_created_with_0600_permissions` +- Failure: `OSError: AF_UNIX path too long` + +--- + +## Changed Files + +- `src/mcpbridge_wrapper/broker/transport.py` +- `tests/unit/test_broker_transport.py` + diff --git a/SPECS/ARCHIVE/_Historical/REVIEW_P14-T1_broker_alias_map_bounds.md b/SPECS/ARCHIVE/_Historical/REVIEW_P14-T1_broker_alias_map_bounds.md new file mode 100644 index 0000000..9a84271 --- /dev/null +++ b/SPECS/ARCHIVE/_Historical/REVIEW_P14-T1_broker_alias_map_bounds.md @@ -0,0 +1,37 @@ +## REVIEW REPORT — P14-T1 Broker Alias Map Bounds + +**Scope:** `origin/main..HEAD` +**Files:** 7 + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +### Critical Issues + +- None. + +### Secondary Issues + +- None. + +### Architectural Notes + +- Alias lifecycle is now bounded to active in-flight requests rather than historical traffic volume. +- Wrap handling in `_alloc_local_id` now avoids collisions with active aliases and fails deterministically on exhaustion. +- Cleanup coverage now includes response routing, write-failure rollback, and shutdown draining paths. + +### Tests + +- `pytest tests/unit/test_broker_transport.py -k 'not SocketPermissions' -q` → `47 passed, 1 deselected` +- `pytest tests/unit/test_broker_transport.py -k 'P14T1MapBounding or mapping_is_released' -q` → `4 passed` +- `ruff check src/` → pass +- `mypy src/` → pass +- `pytest --cov` → one pre-existing environment failure (`AF_UNIX path too long` in `TestSocketPermissions`), overall coverage `91.33%` (>=90%). + +### Next Steps + +- No actionable review findings; FOLLOW-UP is skipped for this task. + diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 306e9ac..79d3d3b 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -1,11 +1,11 @@ -# Next Task: None — All Tasks Completed - -**Status:** Waiting for new tasks +# No Active Task ## Recently Archived -- `FU-P13-T13-FU-1` — Set _stopped_event and _stop_event in _rollback_startup for defensive consistency (2026-02-19, PASS) +- **P14-T1** — Bound per-session ID restore maps in broker transport (2026-02-20, PASS) +- **FU-P13-T13-FU-1** — Set _stopped_event and _stop_event in _rollback_startup for defensive consistency (2026-02-19, PASS) -## Next Step +## Suggested Next Tasks -Add or prioritize follow-up work in `SPECS/Workplan.md`, then run SELECT. +- **P14-T3** — Reconcile declared Python support with tested matrix (P1) +- **P14-T4** — Replace deprecated setuptools license metadata with SPDX format (P2) diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index 87b1a44..ef2b13d 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -2311,7 +2311,7 @@ Phase 9 Follow-up Backlog ### Phase 14: Release 0.4.0 Readiness -#### ⬜️ P14-T1: Bound per-session ID restore maps in broker transport +#### ✅ P14-T1: Bound per-session ID restore maps in broker transport — Completed (2026-02-20, PASS) - **Description:** Prevent unbounded memory growth in long-lived broker sessions by pruning/removing alias/restore entries once responses are routed (and define safe behavior when local ID space wraps). - **Priority:** P1 - **Dependencies:** FU-P13-T11, FU-P13-T15 @@ -2320,9 +2320,9 @@ Phase 9 Follow-up Backlog - Updated `src/mcpbridge_wrapper/broker/transport.py` map lifecycle management for `id_restore`, `string_id_map`, and `int_id_map` - Regression tests in `tests/unit/test_broker_transport.py` for long-lived/large-ID request streams - **Acceptance Criteria:** - - [ ] Per-session restore/alias maps do not grow unbounded for completed requests - - [ ] Existing ID round-trip fidelity guarantees remain intact for int and string IDs - - [ ] Tests cover wrap/prune behavior and pass in CI + - [x] Per-session restore/alias maps do not grow unbounded for completed requests + - [x] Existing ID round-trip fidelity guarantees remain intact for int and string IDs + - [x] Tests cover wrap/prune behavior and pass in CI --- diff --git a/src/mcpbridge_wrapper/broker/transport.py b/src/mcpbridge_wrapper/broker/transport.py index 1e84e22..fedd954 100644 --- a/src/mcpbridge_wrapper/broker/transport.py +++ b/src/mcpbridge_wrapper/broker/transport.py @@ -16,8 +16,8 @@ Responses from upstream carry broker_id; the server extracts ``client_id = broker_id >> 20``, restores ``original_id`` via -``session.id_restore[local_seq]`` in O(1), and routes the response back -to the correct ClientSession. +``session.id_restore[local_seq]`` in O(1), routes the response back +to the correct ClientSession, and releases alias bookkeeping once complete. This design preserves large (> 20-bit), negative, and concurrent integer IDs without truncation or aliasing. (Replaces the lossy ``original_id & 0xFFFFF`` @@ -59,12 +59,34 @@ def _alloc_local_id(session: ClientSession) -> int: # noqa: F821 two original IDs of any type can receive the same local alias within a single session. The counter wraps at ``2^_SESSION_SHIFT - 1`` (skipping 0) rather than at ``2^_SESSION_SHIFT`` so that 0 is reserved for - notifications/null IDs. + notifications/null IDs. Active aliases already present in ``id_restore`` + are skipped so wrapped allocations never collide with in-flight requests. """ - session._next_local_id += 1 - if session._next_local_id >= (1 << _SESSION_SHIFT): - session._next_local_id = 1 # wrap, skipping 0 - return session._next_local_id + max_aliases = _ID_MASK + for _ in range(max_aliases): + session._next_local_id += 1 + if session._next_local_id >= (1 << _SESSION_SHIFT): + session._next_local_id = 1 # wrap, skipping 0 + if session._next_local_id not in session.id_restore: + return session._next_local_id + + raise RuntimeError("No free local request IDs available in this session") + + +def _release_local_alias(session: ClientSession, local_alias: int) -> int | str | None: + """Release alias bookkeeping for ``local_alias`` and return original ID. + + The returned original ID is used to restore response IDs. Forward maps are + pruned only when they still point to the released alias so newer requests + with the same original ID are preserved. + """ + original_id = session.id_restore.pop(local_alias, None) + if isinstance(original_id, str): + if session.string_id_map.get(original_id) == local_alias: + session.string_id_map.pop(original_id, None) + elif isinstance(original_id, int) and session.int_id_map.get(original_id) == local_alias: + session.int_id_map.pop(original_id, None) + return original_id def _get_peer_uid(writer: asyncio.StreamWriter) -> int: @@ -252,10 +274,13 @@ async def route_upstream_response(self, line: str) -> None: ) return - # Restore original request ID via O(1) reverse map. + # Restore original request ID via O(1) reverse map and release alias. # Fall back to int_local_id for sessions that pre-populated pending # without going through _process_client_line (e.g. legacy test fixtures). - original_id: int | str | None = session.id_restore.get(int_local_id, int_local_id) + released_original_id = _release_local_alias(session, int_local_id) + original_id: int | str | None = ( + released_original_id if released_original_id is not None else int_local_id + ) # Rebuild the message with the original ID msg["id"] = original_id @@ -402,6 +427,9 @@ async def _process_client_line(self, session: ClientSession, line: str) -> None: raw_id = msg.get("id") is_notification = raw_id is None + broker_id: int | None = None + local_alias: int | None = None + if not is_notification: # Check TTL during reconnection daemon_state = self._daemon.state @@ -433,25 +461,27 @@ async def _process_client_line(self, session: ClientSession, line: str) -> None: # Remap the request ID using a reversible per-session counter so all # valid JSON-RPC IDs (large, negative, string) round-trip exactly. original_id = raw_id + try: + local_alias = _alloc_local_id(session) + except RuntimeError: + await self._send_error( + session, + original_id, + -32001, + "Broker request ID space exhausted for this session", + ) + return + if isinstance(original_id, str): - # Assign a stable local alias for string IDs. - if original_id not in session.string_id_map: - local_int = _alloc_local_id(session) - session.string_id_map[original_id] = local_int - session.id_restore[local_int] = original_id - int_id = session.string_id_map[original_id] + session.string_id_map[original_id] = local_alias elif isinstance(original_id, int): - # Assign a stable local alias for integer IDs (reversible; no bitmask). - if original_id not in session.int_id_map: - local_int = _alloc_local_id(session) - session.int_id_map[original_id] = local_int - session.id_restore[local_int] = original_id - int_id = session.int_id_map[original_id] + session.int_id_map[original_id] = local_alias else: await self._send_parse_error(session, original_id) return + session.id_restore[local_alias] = original_id - broker_id = (session.session_id << _SESSION_SHIFT) | int_id + broker_id = (session.session_id << _SESSION_SHIFT) | local_alias msg["id"] = broker_id # Track pending request @@ -471,7 +501,10 @@ async def _process_client_line(self, session: ClientSession, line: str) -> None: -32001, "Upstream bridge not available", ) - session.pending.pop(broker_id, None) + if broker_id is not None: + session.pending.pop(broker_id, None) + if local_alias is not None: + _release_local_alias(session, local_alias) return try: @@ -490,7 +523,10 @@ async def _process_client_line(self, session: ClientSession, line: str) -> None: ) if not is_notification: await self._send_error(session, raw_id, -32001, "Upstream write failed") - session.pending.pop(broker_id, None) + if broker_id is not None: + session.pending.pop(broker_id, None) + if local_alias is not None: + _release_local_alias(session, local_alias) async def _broadcast(self, line: str) -> None: """Write ``line`` to all connected client sessions.""" @@ -538,7 +574,10 @@ async def _drain_session(self, session: ClientSession) -> None: fut.cancel() # Restore original_id via O(1) reverse map. int_local_id = broker_id & _ID_MASK - original_id: int | str = session.id_restore.get(int_local_id, int_local_id) + released_original_id = _release_local_alias(session, int_local_id) + original_id: int | str = ( + released_original_id if released_original_id is not None else int_local_id + ) await self._send_error(session, original_id, -32001, "Broker shutting down") session.pending.clear() diff --git a/tests/unit/test_broker_transport.py b/tests/unit/test_broker_transport.py index 0f35caa..4246b16 100644 --- a/tests/unit/test_broker_transport.py +++ b/tests/unit/test_broker_transport.py @@ -35,6 +35,7 @@ _ID_MASK, _SESSION_SHIFT, UnixSocketServer, + _alloc_local_id, _get_peer_uid, ) from mcpbridge_wrapper.broker.types import BrokerConfig, BrokerState, ClientSession @@ -595,22 +596,29 @@ async def test_float_id_sends_parse_error(self, tmp_path: Any) -> None: assert response["error"]["code"] == -32700 @pytest.mark.asyncio - async def test_string_id_reuses_existing_alias(self, tmp_path: Any) -> None: - """Sending the same string ID twice reuses the same integer alias.""" + async def test_string_id_mapping_is_released_after_response(self, tmp_path: Any) -> None: + """A completed string-ID request releases alias maps and reallocates safely.""" server = _make_server(tmp_path) session = _make_session(1) server._sessions[1] = session request = json.dumps({"jsonrpc": "2.0", "id": "stable-id", "method": "tools/list"}) - # Send twice await server._process_client_line(session, request) first_alias = session.string_id_map.get("stable-id") + assert first_alias is not None + first_broker_id = (1 << _SESSION_SHIFT) | first_alias + + response = json.dumps({"jsonrpc": "2.0", "id": first_broker_id, "result": {}}) + await server.route_upstream_response(response) + + assert session.string_id_map == {} + assert session.id_restore == {} + assert session.pending == {} await server._process_client_line(session, request) second_alias = session.string_id_map.get("stable-id") - - assert first_alias == second_alias - assert first_alias is not None + assert second_alias is not None + assert second_alias != first_alias @pytest.mark.asyncio async def test_upstream_write_failure_returns_32001(self, tmp_path: Any) -> None: @@ -629,6 +637,10 @@ async def test_upstream_write_failure_returns_32001(self, tmp_path: Any) -> None call_bytes: bytes = session.writer.write.call_args[0][0] response = json.loads(call_bytes.rstrip(b"\n")) assert response["error"]["code"] == -32001 + assert session.pending == {} + assert session.id_restore == {} + assert session.int_id_map == {} + assert session.string_id_map == {} @pytest.mark.asyncio async def test_reconnecting_then_unavailable_returns_32001(self, tmp_path: Any) -> None: @@ -697,6 +709,8 @@ async def test_drain_with_string_id_sends_string_in_error(self, tmp_path: Any) - response = json.loads(call_bytes.rstrip(b"\n")) assert response["id"] == "my-req" assert response["error"]["code"] == -32001 + assert session.id_restore == {} + assert session.string_id_map == {} @pytest.mark.asyncio async def test_route_response_already_done_future_skipped(self, tmp_path: Any) -> None: @@ -704,6 +718,8 @@ async def test_route_response_already_done_future_skipped(self, tmp_path: Any) - server = _make_server(tmp_path) session = _make_session(1) broker_id = (1 << _SESSION_SHIFT) | 2 + session.int_id_map[777] = 2 + session.id_restore[2] = 777 loop = asyncio.get_event_loop() fut: asyncio.Future[str] = loop.create_future() fut.set_result("already done") @@ -713,6 +729,8 @@ async def test_route_response_already_done_future_skipped(self, tmp_path: Any) - response = json.dumps({"jsonrpc": "2.0", "id": broker_id, "result": {}}) # Should not raise InvalidStateError await server.route_upstream_response(response) + assert session.id_restore == {} + assert session.int_id_map == {} # --------------------------------------------------------------------------- @@ -746,6 +764,8 @@ async def test_large_integer_id_round_trips(self, tmp_path: Any) -> None: call_bytes: bytes = session.writer.write.call_args[0][0] decoded = json.loads(call_bytes.rstrip(b"\n")) assert decoded["id"] == large_id + assert session.id_restore == {} + assert session.int_id_map == {} @pytest.mark.asyncio async def test_negative_integer_id_round_trips(self, tmp_path: Any) -> None: @@ -768,6 +788,8 @@ async def test_negative_integer_id_round_trips(self, tmp_path: Any) -> None: call_bytes: bytes = session.writer.write.call_args[0][0] decoded = json.loads(call_bytes.rstrip(b"\n")) assert decoded["id"] == neg_id + assert session.id_restore == {} + assert session.int_id_map == {} @pytest.mark.asyncio async def test_concurrent_int_ids_no_collision(self, tmp_path: Any) -> None: @@ -793,8 +815,8 @@ async def test_concurrent_int_ids_no_collision(self, tmp_path: Any) -> None: assert broker_a != broker_b @pytest.mark.asyncio - async def test_integer_id_reuses_existing_mapping(self, tmp_path: Any) -> None: - """Sending the same integer ID twice reuses the same local alias.""" + async def test_integer_id_mapping_is_released_after_response(self, tmp_path: Any) -> None: + """A completed integer-ID request releases alias maps and reallocates safely.""" server = _make_server(tmp_path) session = _make_session(1) server._sessions[1] = session @@ -802,12 +824,20 @@ async def test_integer_id_reuses_existing_mapping(self, tmp_path: Any) -> None: request = json.dumps({"jsonrpc": "2.0", "id": 42, "method": "tools/list"}) await server._process_client_line(session, request) alias_first = session.int_id_map.get(42) + assert alias_first is not None + broker_id = (1 << _SESSION_SHIFT) | alias_first + + await server.route_upstream_response( + json.dumps({"jsonrpc": "2.0", "id": broker_id, "result": {}}) + ) + assert session.int_id_map == {} + assert session.id_restore == {} + assert session.pending == {} await server._process_client_line(session, request) alias_second = session.int_id_map.get(42) - - assert alias_first == alias_second - assert alias_first is not None + assert alias_second is not None + assert alias_second != alias_first @pytest.mark.asyncio async def test_int_and_string_id_no_collision(self, tmp_path: Any) -> None: @@ -828,6 +858,38 @@ async def test_int_and_string_id_no_collision(self, tmp_path: Any) -> None: assert int_alias != str_alias, "int and string IDs must not share a local alias" +class TestP14T1MapBounding: + @pytest.mark.asyncio + async def test_maps_remain_bounded_for_completed_request_stream(self, tmp_path: Any) -> None: + """Completed requests should not leave historical alias entries behind.""" + server = _make_server(tmp_path) + session = _make_session(1) + server._sessions[1] = session + + for request_id in range(1, 129): + request = json.dumps({"jsonrpc": "2.0", "id": request_id, "method": "tools/list"}) + await server._process_client_line(session, request) + local_alias = session.int_id_map[request_id] + broker_id = (1 << _SESSION_SHIFT) | local_alias + await server.route_upstream_response( + json.dumps({"jsonrpc": "2.0", "id": broker_id, "result": {"ok": True}}) + ) + + assert session.pending == {} + assert session.id_restore == {} + assert session.int_id_map == {} + assert session.string_id_map == {} + + def test_alloc_local_id_skips_active_aliases_after_wrap(self) -> None: + """When the counter wraps, allocator skips aliases still in use.""" + session = _make_session(1) + session._next_local_id = _ID_MASK + session.id_restore[1] = "active" + + allocated = _alloc_local_id(session) + assert allocated == 2 + + # --------------------------------------------------------------------------- # FU-P13-T12: Peer credential verification # ---------------------------------------------------------------------------