compose_bridge_checks.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """Exercise the production collector with disposable local process groups."""
  2. import importlib.util
  3. import io
  4. import json
  5. import os
  6. from pathlib import Path
  7. import subprocess
  8. import sys
  9. import tempfile
  10. import threading
  11. import time
  12. import unittest
  13. import uuid
  14. from unittest.mock import patch
  15. DIRECTORY = Path(__file__).resolve().parents[3] / 'deploy' / 'computer'
  16. sys.path.insert(0, str(DIRECTORY))
  17. spec = importlib.util.spec_from_file_location('compose_bridge', DIRECTORY / 'compose_bridge.py')
  18. bridge = importlib.util.module_from_spec(spec)
  19. spec.loader.exec_module(bridge)
  20. def start_local(argv, cwd, user='cua'):
  21. return subprocess.Popen(argv, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
  22. stderr=subprocess.PIPE, start_new_session=True)
  23. def request(path, body):
  24. data = json.dumps({'desktopId': 'test', 'token': 'token', 'instance': bridge.INSTANCE, **body}).encode()
  25. handler = object.__new__(bridge.Handler)
  26. handler.path = path
  27. handler.headers = {'Content-Length': str(len(data))}
  28. handler.rfile, handler.wfile = io.BytesIO(data), io.BytesIO()
  29. statuses = []
  30. handler.send_response = statuses.append
  31. handler.send_header = lambda *args: None
  32. handler.end_headers = lambda: None
  33. handler.do_POST()
  34. return statuses[0], json.loads(handler.wfile.getvalue())
  35. class CommandChecks(unittest.TestCase):
  36. def test_cancel_stops_only_its_process_group_and_keeps_peer_and_lease(self):
  37. with tempfile.TemporaryDirectory() as directory:
  38. ready, late, peer = [Path(directory) / name for name in ('ready', 'late', 'peer')]
  39. child = "import pathlib,time;time.sleep(.6);pathlib.Path(%r).write_text('late')" % str(late)
  40. script = "import pathlib,subprocess,sys,time;subprocess.Popen([sys.executable,'-c',%r]);pathlib.Path(%r).touch();time.sleep(30)" % (child, str(ready))
  41. peer_script = "import pathlib,time;time.sleep(.3);pathlib.Path(%r).write_text('ok')" % str(peer)
  42. lease = {'token': 'token', 'expires': time.monotonic() + 90, 'active': 0}
  43. key, results, started = str(uuid.uuid4()), {}, []
  44. def launch(argv, cwd, user='cua'):
  45. process = start_local(argv, directory)
  46. started.append(process)
  47. return process
  48. with patch.object(bridge, 'DESKTOP_ID', 'test'), patch.object(bridge, 'LEASE', lease), \
  49. patch.object(bridge, 'start', side_effect=launch), patch.object(bridge, 'reset_desktop') as reset:
  50. threads = [threading.Thread(target=lambda: results.update(cancelled=request('/execute', {'operationId': key, 'argv': [sys.executable, '-c', script]}))),
  51. threading.Thread(target=lambda: results.update(peer=request('/execute', {'operationId': str(uuid.uuid4()), 'argv': [sys.executable, '-c', peer_script]})))]
  52. try:
  53. for thread in threads:
  54. thread.start()
  55. deadline = time.monotonic() + 3
  56. while not ready.exists() and time.monotonic() < deadline:
  57. time.sleep(.01)
  58. self.assertTrue(ready.exists())
  59. self.assertEqual(request('/cancel', {'operationId': key}), (200, {'ok': True, 'stopped': True, 'operationId': key}))
  60. for thread in threads:
  61. thread.join(3)
  62. self.assertFalse(thread.is_alive())
  63. self.assertEqual(results['cancelled'][0], 409)
  64. self.assertEqual(results['peer'][0], 200)
  65. self.assertEqual(peer.read_text(), 'ok')
  66. time.sleep(.5)
  67. self.assertFalse(late.exists(), 'cancel left a child running')
  68. self.assertEqual(lease['active'], 0)
  69. self.assertIs(bridge.LEASE, lease)
  70. reset.assert_not_called()
  71. finally:
  72. for process in started:
  73. bridge.stop_command(process)
  74. for thread in threads:
  75. thread.join(3)
  76. def test_cancel_before_execute_prevents_late_launch_and_requires_the_current_lease(self):
  77. lease = {'token': 'token', 'expires': time.monotonic() + 90, 'active': 0}
  78. key = str(uuid.uuid4())
  79. with patch.object(bridge, 'DESKTOP_ID', 'test'), patch.object(bridge, 'LEASE', lease), patch.object(bridge, 'start') as launch:
  80. self.assertEqual(request('/cancel', {'operationId': key, 'token': 'wrong'})[0], 409)
  81. self.assertEqual(request('/cancel', {'operationId': key})[0], 200)
  82. self.assertEqual(request('/execute', {'operationId': key, 'argv': ['never-start']})[0], 409)
  83. self.assertEqual(request('/cancel', {'operationId': key})[0], 200)
  84. launch.assert_not_called()
  85. def test_timeout_does_not_restart_another_holders_desktop(self):
  86. with tempfile.TemporaryDirectory() as directory:
  87. marker = Path(directory) / 'should-not-exist'
  88. script = "import pathlib,time; time.sleep(.4); pathlib.Path(%r).write_text('late')" % str(marker)
  89. command = "import subprocess,sys,time; subprocess.Popen([sys.executable,'-c',%r]); time.sleep(30)" % script
  90. # Keep one peer active; exercise the actual HTTP handler error path.
  91. lease = {'token': 'token', 'expires': time.monotonic() + 90, 'active': 1}
  92. body = json.dumps({'desktopId': 'test', 'token': 'token', 'instance': bridge.INSTANCE,
  93. 'argv': [sys.executable, '-c', command], 'timeout': 1000}).encode()
  94. handler = object.__new__(bridge.Handler)
  95. handler.path = '/execute'
  96. handler.headers = {'Content-Length': str(len(body))}
  97. handler.rfile = io.BytesIO(body)
  98. handler.wfile = io.BytesIO()
  99. statuses = []
  100. handler.send_response = statuses.append
  101. handler.send_header = lambda *args: None
  102. handler.end_headers = lambda: None
  103. # Make the timeout short enough to catch a surviving child before it writes.
  104. started = []
  105. def launch(argv, cwd, user='cua'):
  106. process = start_local(argv, directory)
  107. started.append(process)
  108. return process
  109. original = bridge.collect
  110. with patch.object(bridge, 'DESKTOP_ID', 'test'), patch.object(bridge, 'LEASE', lease), \
  111. patch.object(bridge, 'start', side_effect=launch), \
  112. patch.object(bridge, 'collect', side_effect=lambda p, data, timeout: original(p, data, .15)), \
  113. patch.object(bridge, 'reset_desktop') as reset:
  114. try:
  115. handler.do_POST()
  116. self.assertEqual(statuses, [409])
  117. reset.assert_not_called()
  118. self.assertIs(bridge.LEASE, lease)
  119. self.assertEqual(lease['active'], 1)
  120. self.assertIsNotNone(started[0].poll())
  121. time.sleep(.5)
  122. self.assertFalse(marker.exists(), 'timed out command left a child process running')
  123. finally:
  124. for process in started:
  125. try:
  126. os.killpg(process.pid, 9)
  127. except ProcessLookupError:
  128. pass
  129. process.wait()
  130. def test_output_cap_is_enforced_while_the_process_is_still_writing(self):
  131. with patch.object(bridge, 'start', side_effect=start_local), patch.object(bridge, 'reset_desktop') as reset:
  132. before = time.monotonic()
  133. with self.assertRaisesRegex(ValueError, '输出超过限制'):
  134. bridge.run([sys.executable, '-c', "import os,time; os.write(1,b'x'*(9*1024*1024)); time.sleep(10)"], cwd='.', timeout=3)
  135. self.assertLess(time.monotonic() - before, 2)
  136. reset.assert_not_called()
  137. def test_large_input_and_both_output_streams_make_progress(self):
  138. with patch.object(bridge, 'start', side_effect=start_local):
  139. script = "import sys; sys.stdout.write('o'*200000); sys.stdout.flush(); sys.stderr.write('e'*200000); sys.stderr.flush(); data=sys.stdin.buffer.read(); print(len(data))"
  140. result = bridge.run([sys.executable, '-c', script], cwd='.', data=b'i' * 1000000, timeout=3)
  141. self.assertEqual(result['exitCode'], 0)
  142. self.assertEqual(result['stdout'], 'o' * 200000 + '1000000\n')
  143. self.assertEqual(result['stderr'], 'e' * 200000)
  144. if __name__ == '__main__':
  145. result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(CommandChecks))
  146. if not result.wasSuccessful():
  147. raise SystemExit(1)
  148. print('COMPOSE_BRIDGE_CHECKS_OK')