From e15dd583b900ca293db9fe21195e74bb7d683c04 Mon Sep 17 00:00:00 2001 From: Tanmoy Sarkar Date: Tue, 9 Jun 2026 23:04:40 +0530 Subject: [PATCH] test(companion): Add state transition tests Add end-to-end chains over the per-unit tests: spawn to STARTING, promote to RUNNING, unexpected exit to BACKOFF, retry back to STARTING; the stop path ending in manual STOPPED; and the restart path that respawns immediately when the old child exits. Co-Authored-By: Claude Opus 4.8 --- docs/design/companion-process-manager.md | 2 +- tests/test_companion_manager.py | 48 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/design/companion-process-manager.md b/docs/design/companion-process-manager.md index 2c3f2ed6..5d41b4f4 100644 --- a/docs/design/companion-process-manager.md +++ b/docs/design/companion-process-manager.md @@ -696,7 +696,7 @@ No per-companion logic in Arbiter. - [x] Add parent-death cleanup. - [x] Add lifecycle logs. - [x] Add tests for config validation. -- [ ] Add tests for state transitions. +- [x] Add tests for state transitions. - [ ] Add tests for control commands. - [ ] Add tests for transactional reread. - [ ] Add tests that HTTP worker behavior is unchanged. diff --git a/tests/test_companion_manager.py b/tests/test_companion_manager.py index a8a35c5e..3016572b 100644 --- a/tests/test_companion_manager.py +++ b/tests/test_companion_manager.py @@ -579,3 +579,51 @@ def test_spawn_parent_records_pid_and_starting(): assert proc.state == State.STARTING assert proc.started_at is not None assert proc.manual_stop is False + + +def test_lifecycle_running_crash_backoff_retry(): + manager = make_manager("rq") + process = manager.processes["rq"] + assert process.state == State.STOPPED + with mock.patch("os.fork", return_value=100): + manager.spawn_process(process) + assert process.state == State.STARTING + manager.promote_running(now=process.started_at + process.config.startsecs) + assert process.state == State.RUNNING + manager.handle_exit(process, now=1000.0) + assert process.state == State.BACKOFF + assert process.next_retry_at == 1000.0 + process.restart_delay + with mock.patch("os.fork", return_value=101): + manager.retry_backoff(now=process.next_retry_at) + assert process.state == State.STARTING + + +def test_lifecycle_stop_to_stopped(): + manager = make_manager("rq") + process = manager.processes["rq"] + with mock.patch("os.fork", return_value=200): + manager.spawn_process(process) + manager.promote_running(now=process.started_at + process.config.startsecs) + with mock.patch("os.kill") as kill: + manager.stop_process("rq", now=500.0) + assert process.state == State.STOPPING + assert process.manual_stop is True + kill.assert_called_once() + manager.handle_exit(process, now=501.0) + assert process.state == State.STOPPED + + +def test_lifecycle_restart_respawns_after_exit(): + manager = make_manager("rq") + process = manager.processes["rq"] + with mock.patch("os.fork", return_value=300): + manager.spawn_process(process) + manager.promote_running(now=process.started_at + process.config.startsecs) + with mock.patch("os.kill"): + manager.restart_process("rq", now=600.0) + assert process.state == State.STOPPING + assert process.restart_pending is True + with mock.patch("os.fork", return_value=301): + manager.handle_exit(process, now=601.0) + assert process.state == State.STARTING + assert process.restart_pending is False