import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager from free_claude_code.cli.managed.session import ManagedClaudeSession def _manager() -> ManagedClaudeSessionManager: return ManagedClaudeSessionManager( workspace_path="/tmp", proxy_root_url="http://127.0.0.1:8082", ) def _mock_session(*stop_results: object) -> MagicMock: session = MagicMock(spec=ManagedClaudeSession) session.is_busy = False session.stop = AsyncMock(side_effect=list(stop_results)) return session def _completed_process(pid: int) -> MagicMock: process = MagicMock() process.pid = pid process.returncode = 0 process.stdout.read = AsyncMock(return_value=b"") process.stderr = None process.wait = AsyncMock(return_value=0) return process @pytest.mark.asyncio async def test_stop_is_idempotent_without_a_live_process() -> None: session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") assert await session.stop() is True process = MagicMock() process.pid = 101 process.returncode = 0 process.wait = AsyncMock() session.process = process with ( patch( "free_claude_code.cli.managed.session.kill_pid_tree_best_effort" ) as kill_tree, patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, ): assert await session.stop() is True kill_tree.assert_not_called() process.wait.assert_not_awaited() unregister.assert_called_once_with(101) @pytest.mark.asyncio async def test_stopped_session_reference_cannot_launch_a_new_process() -> None: session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") assert await session.stop() is True with ( patch( "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", new=AsyncMock(return_value=_completed_process(100)), ) as create_process, patch("free_claude_code.cli.managed.session.trace_event") as trace, pytest.raises(RuntimeError, match="closed"), ): async for _ in session.start_task("must not launch"): pass create_process.assert_not_awaited() trace.assert_not_called() assert session.is_busy is False @pytest.mark.asyncio async def test_launch_publication_wins_before_concurrent_stop() -> None: session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") launch_entered = asyncio.Event() release_launch = asyncio.Event() release_stream = asyncio.Event() process = MagicMock() process.pid = 102 process.returncode = None process.stderr = None async def create_process(*_args: object, **_kwargs: object) -> MagicMock: launch_entered.set() await release_launch.wait() return process async def read_stdout(*_args: object, **_kwargs: object) -> bytes: await release_stream.wait() return b"" async def wait_process() -> int: process.returncode = 0 return 0 process.stdout.read = AsyncMock(side_effect=read_stdout) process.wait = AsyncMock(side_effect=wait_process) with ( patch( "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", side_effect=create_process, ), patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), patch("free_claude_code.cli.managed.session.register_pid"), patch("free_claude_code.cli.managed.session.unregister_pid"), ): stream_task = asyncio.create_task( _collect_session_events(session, "launch wins") ) await launch_entered.wait() stop_task = asyncio.create_task(session.stop()) await asyncio.sleep(0) stopped_before_publication = stop_task.done() release_launch.set() assert await asyncio.wait_for(stop_task, timeout=1) is True release_stream.set() events = await asyncio.wait_for(stream_task, timeout=1) with pytest.raises(RuntimeError, match="closed"): async for _ in session.start_task("cannot relaunch"): pass assert events == [{"type": "exit", "code": 0, "stderr": None}] assert stopped_before_publication is False @pytest.mark.asyncio async def test_concurrent_stop_wins_before_launch_publication() -> None: session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") stop_entered = asyncio.Event() release_stop = asyncio.Event() process = MagicMock() process.pid = 103 process.returncode = None async def wait_process() -> int: stop_entered.set() await release_stop.wait() process.returncode = 0 return 0 process.wait = AsyncMock(side_effect=wait_process) session.process = process unexpected_process = _completed_process(106) with ( patch( "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", new=AsyncMock(return_value=unexpected_process), ) as create_process, patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), patch("free_claude_code.cli.managed.session.unregister_pid"), patch("free_claude_code.cli.managed.session.trace_event") as trace, ): stop_task = asyncio.create_task(session.stop()) await stop_entered.wait() stream_task = asyncio.create_task(_collect_session_events(session, "stop wins")) await asyncio.sleep(0) release_stop.set() assert await asyncio.wait_for(stop_task, timeout=1) is True with pytest.raises(RuntimeError, match="closed"): await asyncio.wait_for(stream_task, timeout=1) create_process.assert_not_awaited() trace.assert_not_called() @pytest.mark.asyncio async def test_normal_sequential_starts_remain_allowed_before_stop() -> None: session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") processes = [_completed_process(104), _completed_process(105)] with ( patch( "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", new=AsyncMock(side_effect=processes), ) as create_process, patch("free_claude_code.cli.managed.session.register_pid"), patch("free_claude_code.cli.managed.session.unregister_pid"), ): first = await _collect_session_events(session, "first") second = await _collect_session_events(session, "second") assert first == [{"type": "exit", "code": 0, "stderr": None}] assert second == [{"type": "exit", "code": 0, "stderr": None}] assert create_process.await_count == 2 async def _collect_session_events( session: ManagedClaudeSession, prompt: str, ) -> list[dict]: return [event async for event in session.start_task(prompt)] @pytest.mark.asyncio async def test_failed_stop_keeps_pid_registered_until_retry_confirms_exit() -> None: session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") process = MagicMock() process.pid = 202 process.returncode = None process.wait = AsyncMock(side_effect=[RuntimeError("wait failed"), 0]) session.process = process with ( patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, ): assert await session.stop() is False unregister.assert_not_called() assert await session.stop() is True unregister.assert_called_once_with(202) @pytest.mark.asyncio async def test_cancelled_stop_keeps_pid_registered_and_can_be_retried() -> None: session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") process = MagicMock() process.pid = 303 process.returncode = None wait_entered = asyncio.Event() async def wait_until_cancelled() -> int: wait_entered.set() await asyncio.Event().wait() return 0 process.wait = AsyncMock(side_effect=wait_until_cancelled) session.process = process with ( patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, ): stopping = asyncio.create_task(session.stop()) await wait_entered.wait() stopping.cancel() with pytest.raises(asyncio.CancelledError): await stopping unregister.assert_not_called() process.wait = AsyncMock(return_value=0) assert await session.stop() is True unregister.assert_called_once_with(303) @pytest.mark.asyncio async def test_task_failure_keeps_unconfirmed_process_pid_registered() -> None: session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") process = MagicMock() process.pid = 404 process.returncode = None process.stdout.read = AsyncMock(side_effect=RuntimeError("read failed")) process.stderr = None with ( patch( "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", new=AsyncMock(return_value=process), ), patch("free_claude_code.cli.managed.session.register_pid") as register, patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, pytest.raises(RuntimeError, match="read failed"), ): async for _ in session.start_task("hello"): pass register.assert_called_once_with(404) unregister.assert_not_called() @pytest.mark.asyncio async def test_completed_task_wait_unregisters_confirmed_process_pid() -> None: session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") process = MagicMock() process.pid = 405 process.returncode = None process.stdout.read = AsyncMock(return_value=b"") process.stderr = None process.wait = AsyncMock(return_value=0) with ( patch( "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", new=AsyncMock(return_value=process), ), patch("free_claude_code.cli.managed.session.register_pid") as register, patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, ): events = [event async for event in session.start_task("hello")] assert events == [{"type": "exit", "code": 0, "stderr": None}] register.assert_called_once_with(405) unregister.assert_called_once_with(405) @pytest.mark.parametrize( ("first_stop", "raised"), [ (False, None), (RuntimeError("stop failed"), RuntimeError), (asyncio.CancelledError(), asyncio.CancelledError), ], ) @pytest.mark.asyncio async def test_remove_failure_retains_exact_aliases_and_closes_session_for_reuse( first_stop: object, raised: type[BaseException] | None, ) -> None: manager = _manager() session = _mock_session(first_stop, True) manager._sessions["real_1"] = session manager._temp_to_real["temp_1"] = "real_1" manager._real_to_temp["real_1"] = "temp_1" expected_sessions = dict(manager._sessions) expected_temp_to_real = dict(manager._temp_to_real) expected_real_to_temp = dict(manager._real_to_temp) if raised is None: assert await manager.remove_session("real_1") is False else: with pytest.raises(raised): await manager.remove_session("real_1") assert manager._sessions == expected_sessions assert manager._temp_to_real == expected_temp_to_real assert manager._real_to_temp == expected_real_to_temp with pytest.raises(RuntimeError, match="closing"): await manager.get_or_create_session("real_1") with pytest.raises(RuntimeError, match="closing"): await manager.get_or_create_session("temp_1") assert await manager.remove_session("temp_1") is True assert manager._sessions == {} assert manager._temp_to_real == {} assert manager._real_to_temp == {} assert session.stop.await_count == 2 @pytest.mark.asyncio async def test_register_rejects_a_pending_session_after_stop_failure() -> None: manager = _manager() session = _mock_session(False) manager._pending_sessions["temp_1"] = session assert await manager.remove_session("temp_1") is False assert await manager.register_real_session_id("temp_1", "real_1") is False assert manager._pending_sessions == {"temp_1": session} assert manager._sessions == {} assert manager._temp_to_real == {} assert manager._real_to_temp == {} @pytest.mark.asyncio async def test_register_rejects_real_id_owned_by_a_different_session() -> None: manager = _manager() existing = _mock_session(False) pending = _mock_session(True) manager._sessions["real_1"] = existing manager._pending_sessions["temp_2"] = pending assert await manager.register_real_session_id("temp_2", "real_1") is False assert manager._sessions == {"real_1": existing} assert manager._pending_sessions == {"temp_2": pending} assert manager._temp_to_real == {} assert manager._real_to_temp == {} @pytest.mark.asyncio async def test_stop_all_never_loses_a_closing_owner_outside_identity_maps() -> None: manager = _manager() orphaned_owner = _mock_session(False) mapped_owner = _mock_session(True) manager._closing_sessions.add(orphaned_owner) manager._sessions["real_1"] = mapped_owner with pytest.raises( RuntimeError, match=r"^Managed Claude session shutdown failures: 1\.$", ): await manager.stop_all() orphaned_owner.stop.assert_awaited_once() mapped_owner.stop.assert_awaited_once() assert manager._sessions == {} assert manager._closing_sessions == {orphaned_owner} orphaned_owner.stop = AsyncMock(return_value=True) await manager.stop_all() orphaned_owner.stop.assert_awaited_once() assert manager._closing_sessions == set() @pytest.mark.asyncio async def test_stop_all_attempts_every_session_and_retains_only_failures() -> None: manager = _manager() returned_false = _mock_session(False) raised_error = _mock_session(RuntimeError("secret failure detail")) stopped = _mock_session(True) manager._sessions.update( { "real_error": raised_error, "real_false": returned_false, "real_stopped": stopped, } ) manager._temp_to_real.update( { "temp_error": "real_error", "temp_false": "real_false", "temp_stopped": "real_stopped", } ) manager._real_to_temp.update( { "real_error": "temp_error", "real_false": "temp_false", "real_stopped": "temp_stopped", } ) with pytest.raises(RuntimeError) as exc_info: await manager.stop_all() assert str(exc_info.value) == "Managed Claude session shutdown failures: 2." returned_false.stop.assert_awaited_once() raised_error.stop.assert_awaited_once() stopped.stop.assert_awaited_once() assert manager._sessions == { "real_error": raised_error, "real_false": returned_false, } assert manager._pending_sessions == {} assert manager._temp_to_real == { "temp_error": "real_error", "temp_false": "real_false", } assert manager._real_to_temp == { "real_error": "temp_error", "real_false": "temp_false", } with pytest.raises(RuntimeError, match="closing"): await manager.get_or_create_session("temp_false") with pytest.raises(RuntimeError, match="closing"): await manager.get_or_create_session("temp_error") returned_false.stop = AsyncMock(return_value=True) raised_error.stop = AsyncMock(return_value=True) await manager.stop_all() assert manager._sessions == {} assert manager._pending_sessions == {} assert manager._temp_to_real == {} assert manager._real_to_temp == {}