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.
- {shrinkray-26.2.4.0/src/shrinkray.egg-info → shrinkray-26.2.4.1}/PKG-INFO +1 -1
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/pyproject.toml +1 -1
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/client.py +21 -1
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/tui.py +4 -1
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1/src/shrinkray.egg-info}/PKG-INFO +1 -1
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_subprocess_client.py +120 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_tui.py +9 -6
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/LICENSE +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/README.md +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/setup.cfg +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/__init__.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/__main__.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/cli.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/formatting.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/history.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/__init__.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/bytes.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/clangdelta.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/definitions.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/genericlanguages.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/json.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/patching.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/python.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/sat.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/sequences.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/problem.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/process.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/py.typed +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/reducer.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/state.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/__init__.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/protocol.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/worker.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/ui.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/validation.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray/work.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/SOURCES.txt +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/dependency_links.txt +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/entry_points.txt +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/requires.txt +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/top_level.txt +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_byte_reduction_passes.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_clang_delta.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_cli.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_definitions.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_dimacs_cnf.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_formatting.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_generic_language.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_generic_shrinking_properties.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_history.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_json_passes.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_main.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_misc_reduction_performance.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_natural_sort_orders.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_patching.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_problem.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_process.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_python_reducers.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_reducer.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_reduction_passes.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_sat.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_state.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_subprocess_integration.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_subprocess_protocol.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_subprocess_worker.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_tui_snapshots.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_ui.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_validation.py +0 -0
- {shrinkray-26.2.4.0 → shrinkray-26.2.4.1}/tests/test_work.py +0 -0
|
@@ -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=
|
|
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.
|
|
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:
|
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
5504
|
+
# Make close raise an exception
|
|
5502
5505
|
async def raise_exception():
|
|
5503
5506
|
raise RuntimeError("Process already exited")
|
|
5504
5507
|
|
|
5505
|
-
fake_client.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|