Commit 147be052f1c7182e4fdc6fcec82586c8d319e6b7
1 parent
701f6137
deploy_rollback: stop leaking the revived process on every rollback_path_b failure
_launch_candidate() spawns a real `python -m src.backend_server` process (`revived`) to re-verify the previous version during a post-cutover rollback. On the success path it's correctly left running -- it IS the new Live. But on every failure path (never bound live_port, failed re-verification, git revert conflict) nothing ever terminated it -- found via 31 accumulated orphaned processes on this machine, one per failed rollback_path_b call across many real test runs this session. This is also the likely cause of RollbackPathBFailureTest's own reported non-determinism (three independent real Validator sessions saw it fail, never reproduced manually): a leaked process from an earlier failed run can still be squatting on the port a later run's _find_free_port() picks, producing a false "alive" health-check reading. Fix: call deploy_cutover._stop_process(revived.pid) in both failure branches before raising, mirroring the pattern cutover() already uses for its own stop_timeout-bounded process teardown. Also fixes RollbackPathBHappyPathTest's own matching leak (it re-appended bad_live.pid a second time instead of tracking the actual revived process, a copy-paste bug, not real cleanup) by capturing the real pid via a thin _launch_candidate wrapper. 31 pre-existing orphaned processes killed by hand; verified 5 repeated test runs after the fix leak zero new ones. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
36 additions
and
7 deletions
supervisor/deploy_rollback.py
| @@ -141,6 +141,7 @@ def rollback_path_b( | @@ -141,6 +141,7 @@ def rollback_path_b( | ||
| 141 | conn.execute("UPDATE deployment SET status = 'rollback_in_progress' WHERE deployment_id = ?", (deployment_id,)) | 141 | conn.execute("UPDATE deployment SET status = 'rollback_in_progress' WHERE deployment_id = ?", (deployment_id,)) |
| 142 | conn.commit() | 142 | conn.commit() |
| 143 | 143 | ||
| 144 | + revived = None | ||
| 144 | try: | 145 | try: |
| 145 | deploy_cutover._stop_process(live_pid, timeout=stop_timeout) | 146 | deploy_cutover._stop_process(live_pid, timeout=stop_timeout) |
| 146 | 147 | ||
| @@ -167,12 +168,26 @@ def rollback_path_b( | @@ -167,12 +168,26 @@ def rollback_path_b( | ||
| 167 | 168 | ||
| 168 | _git_revert(repo_root, git_commit_hash) | 169 | _git_revert(repo_root, git_commit_hash) |
| 169 | except subprocess.CalledProcessError as e: | 170 | except subprocess.CalledProcessError as e: |
| 171 | + # 2026-08-05 real bug found: `revived` was never stopped on any | ||
| 172 | + # failure path -- every failed rollback_path_b call leaked a real | ||
| 173 | + # `python -m src.backend_server` process forever (found via 31 | ||
| 174 | + # accumulated orphans on this machine, and very likely the cause of | ||
| 175 | + # RollbackPathBFailureTest's own reported non-determinism: a stale | ||
| 176 | + # leaked process from an earlier failed run can still be squatting | ||
| 177 | + # on the port a later run's `_find_free_port()` picks, producing a | ||
| 178 | + # false "alive" health-check reading). On the SUCCESS path `revived` | ||
| 179 | + # is deliberately left running -- it IS the new Live -- so this | ||
| 180 | + # cleanup only belongs in the failure branches. | ||
| 181 | + if revived is not None: | ||
| 182 | + deploy_cutover._stop_process(revived.pid, timeout=stop_timeout) | ||
| 170 | _abort_revert_if_in_progress(repo_root) | 183 | _abort_revert_if_in_progress(repo_root) |
| 171 | _block(conn, deployment_id, "rollback_failed (path B): git revert failed: {}".format(e.stderr)) | 184 | _block(conn, deployment_id, "rollback_failed (path B): git revert failed: {}".format(e.stderr)) |
| 172 | raise RollbackFailedError( | 185 | raise RollbackFailedError( |
| 173 | "rollback path B failed for deployment {}: git revert error: {}".format(deployment_id, e.stderr) | 186 | "rollback path B failed for deployment {}: git revert error: {}".format(deployment_id, e.stderr) |
| 174 | ) from e | 187 | ) from e |
| 175 | except RollbackFailedError as e: | 188 | except RollbackFailedError as e: |
| 189 | + if revived is not None: | ||
| 190 | + deploy_cutover._stop_process(revived.pid, timeout=stop_timeout) | ||
| 176 | _block(conn, deployment_id, "rollback_failed (path B): {}".format(e)) | 191 | _block(conn, deployment_id, "rollback_failed (path B): {}".format(e)) |
| 177 | raise | 192 | raise |
| 178 | 193 |
supervisor/test_deploy_rollback.py
| @@ -15,6 +15,7 @@ import sys | @@ -15,6 +15,7 @@ import sys | ||
| 15 | import tempfile | 15 | import tempfile |
| 16 | import time | 16 | import time |
| 17 | import unittest | 17 | import unittest |
| 18 | +from unittest.mock import patch | ||
| 18 | 19 | ||
| 19 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | 20 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| 20 | 21 | ||
| @@ -222,13 +223,26 @@ class RollbackPathBHappyPathTest(DeployRollbackTestBase): | @@ -222,13 +223,26 @@ class RollbackPathBHappyPathTest(DeployRollbackTestBase): | ||
| 222 | ) | 223 | ) |
| 223 | 224 | ||
| 224 | rollback_port = deploy_stage._find_free_port() | 225 | rollback_port = deploy_stage._find_free_port() |
| 225 | - result = deploy_rollback.rollback_path_b( | ||
| 226 | - self.conn, "dep-b1", bad_live.pid, self.prev_dir, self.prod_db_path, self.commit_v2, | ||
| 227 | - repo_root=self.repo_dir, live_port=rollback_port, | ||
| 228 | - ) | ||
| 229 | - self._cleanup_pids.append( # find the revived process's pid via port not tracked -- best-effort cleanup | ||
| 230 | - bad_live.pid | ||
| 231 | - ) | 226 | + # 2026-08-05: capture the real revived process's pid for cleanup -- |
| 227 | + # rollback_path_b()'s own return value doesn't expose it (by design, | ||
| 228 | + # it's meant to become the new Live, not a tracked handle), and this | ||
| 229 | + # test used to just re-append bad_live.pid a second time here | ||
| 230 | + # (a copy-paste mistake, not real cleanup), leaking the revived | ||
| 231 | + # process on every happy-path test run. | ||
| 232 | + real_launch = deploy_stage._launch_candidate | ||
| 233 | + revived_holder = {} | ||
| 234 | + | ||
| 235 | + def _capturing_launch(*args, **kwargs): | ||
| 236 | + proc = real_launch(*args, **kwargs) | ||
| 237 | + revived_holder["proc"] = proc | ||
| 238 | + return proc | ||
| 239 | + | ||
| 240 | + with patch.object(deploy_stage, "_launch_candidate", side_effect=_capturing_launch): | ||
| 241 | + result = deploy_rollback.rollback_path_b( | ||
| 242 | + self.conn, "dep-b1", bad_live.pid, self.prev_dir, self.prod_db_path, self.commit_v2, | ||
| 243 | + repo_root=self.repo_dir, live_port=rollback_port, | ||
| 244 | + ) | ||
| 245 | + self._cleanup_pids.append(revived_holder["proc"].pid) | ||
| 232 | 246 | ||
| 233 | self.assertEqual(result["status"], "rolled_back") | 247 | self.assertEqual(result["status"], "rolled_back") |
| 234 | self.assertFalse(deploy_supervisor.is_alive(bad_live.pid)) | 248 | self.assertFalse(deploy_supervisor.is_alive(bad_live.pid)) |