Commit 147be052f1c7182e4fdc6fcec82586c8d319e6b7

Authored by bboonnee
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>
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))