mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-29 20:05:25 +08:00
fix(regression): recover stats EOF after verified sandbox deletion
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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__':
|
||||
|
||||
Reference in New Issue
Block a user