shrinkray 26.2.4.0__tar.gz → 26.2.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {shrinkray-26.2.4.0/src/shrinkray.egg-info → shrinkray-26.2.4.1}/PKG-INFO +1 -1
  2. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/pyproject.toml +1 -1
  3. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/client.py +21 -1
  4. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/tui.py +4 -1
  5. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1/src/shrinkray.egg-info}/PKG-INFO +1 -1
  6. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_subprocess_client.py +120 -0
  7. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_tui.py +9 -6
  8. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/LICENSE +0 -0
  9. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/README.md +0 -0
  10. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/setup.cfg +0 -0
  11. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/__init__.py +0 -0
  12. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/__main__.py +0 -0
  13. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/cli.py +0 -0
  14. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/formatting.py +0 -0
  15. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/history.py +0 -0
  16. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/__init__.py +0 -0
  17. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/bytes.py +0 -0
  18. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/clangdelta.py +0 -0
  19. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/definitions.py +0 -0
  20. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/genericlanguages.py +0 -0
  21. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/json.py +0 -0
  22. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/patching.py +0 -0
  23. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/python.py +0 -0
  24. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/sat.py +0 -0
  25. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/sequences.py +0 -0
  26. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/problem.py +0 -0
  27. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/process.py +0 -0
  28. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/py.typed +0 -0
  29. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/reducer.py +0 -0
  30. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/state.py +0 -0
  31. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/__init__.py +0 -0
  32. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/protocol.py +0 -0
  33. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/worker.py +0 -0
  34. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/ui.py +0 -0
  35. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/validation.py +0 -0
  36. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/work.py +0 -0
  37. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/SOURCES.txt +0 -0
  38. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/dependency_links.txt +0 -0
  39. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/entry_points.txt +0 -0
  40. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/requires.txt +0 -0
  41. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/top_level.txt +0 -0
  42. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_byte_reduction_passes.py +0 -0
  43. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_clang_delta.py +0 -0
  44. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_cli.py +0 -0
  45. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_definitions.py +0 -0
  46. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_dimacs_cnf.py +0 -0
  47. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_formatting.py +0 -0
  48. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_generic_language.py +0 -0
  49. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_generic_shrinking_properties.py +0 -0
  50. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_history.py +0 -0
  51. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_json_passes.py +0 -0
  52. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_main.py +0 -0
  53. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_misc_reduction_performance.py +0 -0
  54. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_natural_sort_orders.py +0 -0
  55. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_patching.py +0 -0
  56. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_problem.py +0 -0
  57. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_process.py +0 -0
  58. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_python_reducers.py +0 -0
  59. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_reducer.py +0 -0
  60. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_reduction_passes.py +0 -0
  61. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_sat.py +0 -0
  62. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_state.py +0 -0
  63. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_subprocess_integration.py +0 -0
  64. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_subprocess_protocol.py +0 -0
  65. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_subprocess_worker.py +0 -0
  66. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_tui_snapshots.py +0 -0
  67. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_ui.py +0 -0
  68. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_validation.py +0 -0
  69. {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 26.2.4.0
3
+ Version: 26.2.4.1
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shrinkray"
3
- version = "26.2.4.0"
3
+ version = "26.2.4.1"
4
4
  description = "Shrink Ray"
5
5
  authors = [
6
6
  {name = "David R. MacIver", email = "david@drmaciver.com"}
@@ -27,6 +27,7 @@ class SubprocessClient:
27
27
  self._progress_queue: asyncio.Queue[ProgressUpdate] = asyncio.Queue()
28
28
  self._reader_task: asyncio.Task | None = None
29
29
  self._completed = False
30
+ self._closed = False
30
31
  self._error_message: str | None = None
31
32
  self._debug_mode = debug_mode
32
33
  self._stderr_log_file: IO[str] | None = None
@@ -261,6 +262,17 @@ class SubprocessClient:
261
262
 
262
263
  async def close(self) -> None:
263
264
  """Close the subprocess."""
265
+ if self._closed:
266
+ return
267
+ self._closed = True
268
+
269
+ # Cancel all pending futures first so any code awaiting send_command
270
+ # responses (e.g. cancel() in action_quit) is unblocked immediately.
271
+ for future in self._pending_responses.values():
272
+ if not future.done():
273
+ future.cancel()
274
+ self._pending_responses.clear()
275
+
264
276
  if self._reader_task is not None:
265
277
  self._reader_task.cancel()
266
278
  try:
@@ -278,13 +290,21 @@ class SubprocessClient:
278
290
  if self._process.returncode is None:
279
291
  try:
280
292
  self._process.terminate()
281
- await asyncio.wait_for(self._process.wait(), timeout=5.0)
293
+ await asyncio.wait_for(self._process.wait(), timeout=2.0)
282
294
  except TimeoutError:
283
295
  self._process.kill()
284
296
  await self._process.wait()
285
297
  except ProcessLookupError:
286
298
  pass # Process already exited
287
299
 
300
+ # Always await wait() to ensure the asyncio transport is fully
301
+ # finalized before the event loop closes. This prevents the
302
+ # "Event loop is closed" RuntimeError from BaseSubprocessTransport.__del__.
303
+ if self._process.returncode is not None:
304
+ await self._process.wait()
305
+ # Flush pending event loop callbacks (transport cleanup)
306
+ await asyncio.sleep(0)
307
+
288
308
  # Close and remove the stderr log file
289
309
  if self._stderr_log_file is not None:
290
310
  try:
@@ -1938,11 +1938,14 @@ class ShrinkRayApp(App[None]):
1938
1938
 
1939
1939
  async def action_quit(self) -> None:
1940
1940
  """Quit the application with graceful cancellation."""
1941
+ self.update_status("Shutting down...")
1941
1942
  if self._client and not self._completed:
1942
1943
  try:
1943
- await self._client.cancel()
1944
+ await self._client.close()
1944
1945
  except Exception:
1945
1946
  pass # Process may have already exited
1947
+ # Prevent double-close in run_reduction's finally block
1948
+ self._client = None
1946
1949
  self.exit()
1947
1950
 
1948
1951
  def action_show_pass_stats(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 26.2.4.0
3
+ Version: 26.2.4.1
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -1120,3 +1120,123 @@ def test_subprocess_client_close_handles_stderr_log_file_exception():
1120
1120
  mock_file.close.assert_called_once()
1121
1121
 
1122
1122
  asyncio.run(run())
1123
+
1124
+
1125
+ def test_subprocess_client_close_cancels_pending_futures():
1126
+ """Test close() cancels all pending futures so awaiting code is unblocked."""
1127
+
1128
+ async def run():
1129
+ client = SubprocessClient()
1130
+
1131
+ # Create pending futures
1132
+ loop = asyncio.get_event_loop()
1133
+ future1: asyncio.Future[Response] = loop.create_future()
1134
+ future2: asyncio.Future[Response] = loop.create_future()
1135
+ client._pending_responses["req-1"] = future1
1136
+ client._pending_responses["req-2"] = future2
1137
+
1138
+ await client.close()
1139
+
1140
+ # Both futures should be cancelled
1141
+ assert future1.cancelled()
1142
+ assert future2.cancelled()
1143
+ # Pending responses should be cleared
1144
+ assert len(client._pending_responses) == 0
1145
+
1146
+ asyncio.run(run())
1147
+
1148
+
1149
+ def test_subprocess_client_close_is_idempotent():
1150
+ """Test close() is a no-op on second call due to _closed flag."""
1151
+
1152
+ async def run():
1153
+ client = SubprocessClient()
1154
+
1155
+ # Create a pending future
1156
+ loop = asyncio.get_event_loop()
1157
+ future: asyncio.Future[Response] = loop.create_future()
1158
+ client._pending_responses["req-1"] = future
1159
+
1160
+ await client.close()
1161
+ assert future.cancelled()
1162
+ assert client._closed
1163
+
1164
+ # Add another future after close (simulating a race)
1165
+ future2: asyncio.Future[Response] = loop.create_future()
1166
+ client._pending_responses["req-2"] = future2
1167
+
1168
+ # Second close should be a no-op
1169
+ await client.close()
1170
+
1171
+ # The second future should NOT be cancelled (close was a no-op)
1172
+ assert not future2.cancelled()
1173
+
1174
+ asyncio.run(run())
1175
+
1176
+
1177
+ def test_subprocess_client_close_awaits_wait_after_process_exit():
1178
+ """Test close() always awaits process.wait() after process exits."""
1179
+
1180
+ async def run():
1181
+ client = SubprocessClient()
1182
+
1183
+ # Create a mock process that has already exited
1184
+ mock_process = MagicMock()
1185
+ mock_process.returncode = 0 # Already exited
1186
+ mock_process.stdin = MagicMock()
1187
+ mock_process.stdin.close = MagicMock()
1188
+
1189
+ wait_called = [False]
1190
+
1191
+ async def mock_wait():
1192
+ wait_called[0] = True
1193
+
1194
+ mock_process.wait = mock_wait
1195
+ client._process = mock_process
1196
+ client._reader_task = None
1197
+
1198
+ await client.close()
1199
+
1200
+ # wait() should have been called even though process already exited
1201
+ assert wait_called[0]
1202
+
1203
+ asyncio.run(run())
1204
+
1205
+
1206
+ def test_subprocess_client_close_skips_done_futures():
1207
+ """Test close() skips already-done futures when cancelling."""
1208
+
1209
+ async def run():
1210
+ client = SubprocessClient()
1211
+
1212
+ # Create one done and one pending future
1213
+ loop = asyncio.get_event_loop()
1214
+ done_future: asyncio.Future[Response] = loop.create_future()
1215
+ done_future.set_result(Response(id="done", result={"status": "ok"}))
1216
+ pending_future: asyncio.Future[Response] = loop.create_future()
1217
+
1218
+ client._pending_responses["done"] = done_future
1219
+ client._pending_responses["pending"] = pending_future
1220
+
1221
+ await client.close()
1222
+
1223
+ # Done future should still have its result (not cancelled)
1224
+ assert not done_future.cancelled()
1225
+ assert done_future.result().result == {"status": "ok"}
1226
+ # Pending future should be cancelled
1227
+ assert pending_future.cancelled()
1228
+
1229
+ asyncio.run(run())
1230
+
1231
+
1232
+ def test_subprocess_client_close_handles_stderr_log_unlink_exception():
1233
+ """Test close() handles exception when unlinking stderr log file."""
1234
+
1235
+ async def run():
1236
+ client = SubprocessClient()
1237
+ client._stderr_log_path = "/nonexistent/path/that/will/fail"
1238
+
1239
+ # Should not raise even if unlink fails
1240
+ await client.close()
1241
+
1242
+ asyncio.run(run())
@@ -113,6 +113,7 @@ class FakeReductionClient:
113
113
 
114
114
  async def close(self) -> None:
115
115
  self._closed = True
116
+ self._cancelled = True
116
117
 
117
118
  async def get_progress_updates(self) -> AsyncGenerator[ProgressUpdate, None]:
118
119
  for update in self._updates:
@@ -5459,7 +5460,7 @@ def test_expanded_modal_output_content_without_test_id():
5459
5460
 
5460
5461
 
5461
5462
  def test_action_quit_with_client():
5462
- """Test action_quit cancels client before exiting."""
5463
+ """Test action_quit closes client directly before exiting."""
5463
5464
 
5464
5465
  async def run():
5465
5466
  fake_client = FakeReductionClient(updates=[], wait_indefinitely=True)
@@ -5483,8 +5484,10 @@ def test_action_quit_with_client():
5483
5484
  await pilot.press("q")
5484
5485
  await pilot.pause()
5485
5486
 
5486
- # Client should have been cancelled
5487
- assert fake_client._cancelled
5487
+ # Client should have been closed directly (not just cancelled)
5488
+ assert fake_client._closed
5489
+ # Client reference should be cleared to prevent double-close
5490
+ assert app._client is None
5488
5491
  finally:
5489
5492
  if os.path.exists(temp_file):
5490
5493
  os.unlink(temp_file)
@@ -5493,16 +5496,16 @@ def test_action_quit_with_client():
5493
5496
 
5494
5497
 
5495
5498
  def test_action_quit_handles_exception():
5496
- """Test action_quit handles exception from cancel gracefully."""
5499
+ """Test action_quit handles exception from close gracefully."""
5497
5500
 
5498
5501
  async def run():
5499
5502
  fake_client = FakeReductionClient(updates=[], wait_indefinitely=True)
5500
5503
 
5501
- # Make cancel raise an exception
5504
+ # Make close raise an exception
5502
5505
  async def raise_exception():
5503
5506
  raise RuntimeError("Process already exited")
5504
5507
 
5505
- fake_client.cancel = raise_exception
5508
+ fake_client.close = raise_exception
5506
5509
 
5507
5510
  with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
5508
5511
  f.write("test")
File without changes
File without changes
File without changes