From b1b703414d20885869ff5837cb2bf89b65b4cba8 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:24:57 +0800 Subject: [PATCH] fix(regression): recover stats EOF after verified sandbox deletion --- tools/regression_traffic.py | 9 ++- tools/test_regression_containers.py | 93 ++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/tools/regression_traffic.py b/tools/regression_traffic.py index f2257df87..3dd4c6b2f 100644 --- a/tools/regression_traffic.py +++ b/tools/regression_traffic.py @@ -102,11 +102,14 @@ def sample_containers(configured, data_root, run=subprocess.run, discover=owned_ result = stats(containers) observation = {} missing = missing_containers(result.stderr, containers) if result.returncode == 1 else set() - if missing and data_root: + # Docker 26 can close the stats stream with EOF when a sampled sandbox is deleted. + eof = result.returncode == 1 and result.stderr.strip() == 'EOF' and not result.stdout.strip() + if (missing or eof) and data_root: current = selected() - if missing <= containers - current: + removed = containers - current + if removed and (eof or missing <= removed): observation['container_churn'] = { - 'removed': sorted(containers - current), + 'removed': sorted(removed), 'added': sorted(current - containers), 'first_error': result.stderr.strip(), } diff --git a/tools/test_regression_containers.py b/tools/test_regression_containers.py index a543b702f..ff1e35718 100644 --- a/tools/test_regression_containers.py +++ b/tools/test_regression_containers.py @@ -14,6 +14,82 @@ def description(container, mount): class ContainerSamplingTests(unittest.TestCase): + def test_eof_resamples_survivors_and_new_containers_after_confirmed_deletion(self): + run = Mock( + side_effect=[ + SimpleNamespace(returncode=1, stdout='', stderr='EOF\n'), + SimpleNamespace( + returncode=0, + stdout='{"Container":"configured","PIDs":"1"}\n' + '{"Container":"live","PIDs":"1"}\n{"Container":"new","PIDs":"1"}\n', + stderr='', + ), + ] + ) + result = sample_containers(['configured'], '/owned', run, Mock(side_effect=[['old', 'live'], ['live', 'new']])) + self.assertNotIn('container_error', result) + self.assertEqual(result['container_churn'], {'removed': ['old'], 'added': ['new'], 'first_error': 'EOF'}) + self.assertEqual([entry['Container'] for entry in result['containers']], ['configured', 'live', 'new']) + self.assertEqual(run.call_count, 2) + self.assertEqual(run.call_args.args[0][-3:], ['configured', 'live', 'new']) + + def test_eof_without_confirmed_dynamic_deletion_remains_failure(self): + for configured, discoveries in ( + ([], [['live'], ['live']]), + ([], [['live'], ['live', 'new']]), + (['configured'], [['configured', 'live'], ['live']]), + ): + with self.subTest(configured=configured, discoveries=discoveries): + run = Mock(return_value=SimpleNamespace(returncode=1, stdout='', stderr='EOF')) + result = sample_containers(configured, '/owned', run, Mock(side_effect=discoveries)) + self.assertEqual(result['container_error'], 'EOF') + self.assertNotIn('container_churn', result) + self.assertEqual(run.call_count, 1) + + def test_eof_discovery_failure_cannot_confirm_deletion(self): + error = subprocess.CalledProcessError(1, ['docker', 'ps'], '', 'permission denied') + run = Mock(return_value=SimpleNamespace(returncode=1, stdout='', stderr='EOF')) + with self.assertRaises(subprocess.CalledProcessError) as caught: + sample_containers([], '/owned', run, Mock(side_effect=[['old'], error])) + self.assertIs(caught.exception, error) + self.assertEqual(run.call_count, 1) + + def test_eof_retry_failure_is_not_hidden_or_retried_again(self): + for error in ('EOF', 'permission denied', 'Error response from daemon: No such container: configured'): + with self.subTest(error=error): + run = Mock( + side_effect=[ + SimpleNamespace(returncode=1, stdout='', stderr='EOF'), + SimpleNamespace(returncode=1, stdout='', stderr=error), + ] + ) + result = sample_containers(['configured'], '/owned', run, Mock(side_effect=[['old', 'live'], ['live']])) + self.assertEqual(result['container_error'], error) + self.assertEqual(result['container_churn']['first_error'], 'EOF') + self.assertEqual(run.call_count, 2) + run = Mock( + side_effect=[ + SimpleNamespace(returncode=1, stdout='', stderr='EOF'), + SimpleNamespace(returncode=0, stdout='{truncated', stderr=''), + ] + ) + with self.assertRaises(ValueError): + sample_containers([], '/owned', run, Mock(side_effect=[['old', 'live'], ['live']])) + + def test_only_empty_output_and_pure_exit_one_eof_can_recover(self): + for code, error, stdout in ( + (2, 'EOF', ''), + (1, 'EOF\npermission denied', ''), + (1, 'EOF', '{"Container":"partial"}'), + ): + with self.subTest(code=code, error=error, stdout=stdout): + run = Mock(return_value=SimpleNamespace(returncode=code, stdout=stdout, stderr=error)) + discover = Mock(side_effect=[['old', 'live'], ['live']]) + result = sample_containers([], '/owned', run, discover) + self.assertEqual(result['container_error'], error) + self.assertEqual(run.call_count, 1) + self.assertEqual(discover.call_count, 1) + def test_expiry_between_list_and_inspect_preserves_owned_survivors(self): for error in ( subprocess.CalledProcessError(1, ['docker', 'inspect'], '\n', 'Error: No such object: expired'), @@ -89,15 +165,14 @@ class ContainerSamplingTests(unittest.TestCase): self.assertEqual(run.call_count, 1) def test_all_dynamic_containers_removed_returns_observed_churn(self): - run = Mock( - return_value=SimpleNamespace( - returncode=1, stdout='', stderr='Error response from daemon: No such container: old' - ) - ) - result = sample_containers([], '/owned', run, Mock(side_effect=[['old'], []])) - self.assertEqual(result['containers'], []) - self.assertEqual(result['container_churn']['removed'], ['old']) - self.assertNotIn('container_error', result) + for error in ('Error response from daemon: No such container: old', 'EOF'): + with self.subTest(error=error): + run = Mock(return_value=SimpleNamespace(returncode=1, stdout='', stderr=error)) + result = sample_containers([], '/owned', run, Mock(side_effect=[['old'], []])) + self.assertEqual(result['containers'], []) + self.assertEqual(result['container_churn']['removed'], ['old']) + self.assertEqual(result['container_churn']['first_error'], error) + self.assertNotIn('container_error', result) if __name__ == '__main__':