shrinkray 25.12.29.0__py3-none-any.whl → 26.2.4.0__py3-none-any.whl
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/__main__.py +48 -1
- shrinkray/history.py +446 -0
- shrinkray/state.py +247 -41
- shrinkray/subprocess/client.py +53 -4
- shrinkray/subprocess/protocol.py +8 -0
- shrinkray/subprocess/worker.py +196 -31
- shrinkray/tui.py +570 -49
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/METADATA +2 -5
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/RECORD +13 -12
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/WHEEL +1 -1
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/top_level.txt +0 -0
shrinkray/state.py
CHANGED
|
@@ -17,6 +17,13 @@ import humanize
|
|
|
17
17
|
import trio
|
|
18
18
|
from attrs import define
|
|
19
19
|
|
|
20
|
+
from shrinkray.cli import InputType
|
|
21
|
+
from shrinkray.formatting import default_reformat_data, determine_formatter_command
|
|
22
|
+
from shrinkray.history import (
|
|
23
|
+
HistoryManager,
|
|
24
|
+
deserialize_directory,
|
|
25
|
+
serialize_directory,
|
|
26
|
+
)
|
|
20
27
|
from shrinkray.passes.clangdelta import ClangDelta
|
|
21
28
|
from shrinkray.problem import (
|
|
22
29
|
BasicReductionProblem,
|
|
@@ -24,6 +31,7 @@ from shrinkray.problem import (
|
|
|
24
31
|
ReductionProblem,
|
|
25
32
|
sort_key_for_initial,
|
|
26
33
|
)
|
|
34
|
+
from shrinkray.process import interrupt_wait_and_kill
|
|
27
35
|
from shrinkray.reducer import DirectoryShrinkRay, Reducer, ShrinkRay
|
|
28
36
|
from shrinkray.work import Volume, WorkContext
|
|
29
37
|
|
|
@@ -191,29 +199,113 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
191
199
|
|
|
192
200
|
first_call_time: float | None = None
|
|
193
201
|
|
|
194
|
-
# Lazy imports to break circular dependencies:
|
|
195
|
-
# - shrinkray.process imports from shrinkray.work which imports from here
|
|
196
|
-
# - shrinkray.cli imports from here for state configuration
|
|
197
|
-
# These are cached after first import for performance.
|
|
198
|
-
_interrupt_wait_and_kill: Any = None
|
|
199
|
-
_InputType: Any = None # InputType enum from shrinkray.cli
|
|
200
|
-
|
|
201
202
|
# Stores the output from the last debug run
|
|
202
203
|
_last_debug_output: str = ""
|
|
203
204
|
|
|
204
|
-
#
|
|
205
|
+
# Stores the output from the most recently completed test (for history recording)
|
|
206
|
+
# This is read immediately after the test's output file is closed to avoid
|
|
207
|
+
# race conditions with other parallel tests
|
|
208
|
+
_last_test_output: bytes | None = None
|
|
209
|
+
|
|
210
|
+
# Optional output manager for capturing test output (TUI mode or history)
|
|
205
211
|
output_manager: OutputCaptureManager | None = None
|
|
206
212
|
|
|
213
|
+
# History recording (enabled by default)
|
|
214
|
+
history_enabled: bool = True
|
|
215
|
+
history_base_dir: str | None = None # Base directory for .shrinkray folder
|
|
216
|
+
history_manager: HistoryManager | None = None
|
|
217
|
+
|
|
218
|
+
# Also-interesting exit code (None = disabled)
|
|
219
|
+
# When a test returns this code, it's recorded but not used for reduction
|
|
220
|
+
also_interesting_code: int | None = None
|
|
221
|
+
|
|
222
|
+
# Set of test cases to exclude from interestingness (for restart-from-point)
|
|
223
|
+
# These are byte-identical matches of previously reduced values
|
|
224
|
+
excluded_test_cases: set[bytes] | None = None
|
|
225
|
+
|
|
226
|
+
# Temp directory for output capture (when not using TUI's output manager)
|
|
227
|
+
_output_tempdir: TemporaryDirectory | None = None
|
|
228
|
+
|
|
229
|
+
# Stores output from successful tests, keyed by test case bytes
|
|
230
|
+
# This avoids race conditions when multiple tests run in parallel
|
|
231
|
+
_successful_outputs: dict[bytes, bytes] = {}
|
|
232
|
+
|
|
207
233
|
def __attrs_post_init__(self):
|
|
208
234
|
self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
|
|
235
|
+
self._successful_outputs = {} # Initialize mutable default
|
|
209
236
|
self.setup_formatter()
|
|
237
|
+
self._setup_history()
|
|
210
238
|
|
|
211
239
|
@abstractmethod
|
|
212
240
|
def setup_formatter(self): ...
|
|
213
241
|
|
|
242
|
+
@property
|
|
243
|
+
def is_directory_mode(self) -> bool:
|
|
244
|
+
"""Whether this state manages directory test cases."""
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
def _setup_history(self) -> None:
|
|
248
|
+
"""Set up history recording if enabled or also-interesting is configured."""
|
|
249
|
+
# Create history manager if either:
|
|
250
|
+
# 1. Full history is enabled, or
|
|
251
|
+
# 2. also_interesting_code is set (records only also-interesting cases)
|
|
252
|
+
if not self.history_enabled and self.also_interesting_code is None:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Create history manager (record_reductions=False if only also-interesting)
|
|
256
|
+
self.history_manager = HistoryManager.create(
|
|
257
|
+
self.test,
|
|
258
|
+
self.filename,
|
|
259
|
+
record_reductions=self.history_enabled,
|
|
260
|
+
is_directory=self.is_directory_mode,
|
|
261
|
+
base_dir=self.history_base_dir,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Ensure we have an output manager for capturing test output
|
|
265
|
+
if self.output_manager is None:
|
|
266
|
+
self._output_tempdir = TemporaryDirectory()
|
|
267
|
+
self.output_manager = OutputCaptureManager(
|
|
268
|
+
output_dir=self._output_tempdir.name
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _get_last_captured_output(self) -> bytes | None:
|
|
272
|
+
"""Get the output from the most recently completed test.
|
|
273
|
+
|
|
274
|
+
Returns the output content if available, None otherwise.
|
|
275
|
+
This returns the output that was captured immediately when the test
|
|
276
|
+
completed, avoiding race conditions with other parallel tests.
|
|
277
|
+
"""
|
|
278
|
+
return self._last_test_output
|
|
279
|
+
|
|
280
|
+
def _check_also_interesting(self, exit_code: int, test_case: TestCase) -> None:
|
|
281
|
+
"""Check if exit code matches also-interesting and record if so.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
exit_code: The exit code from the test
|
|
285
|
+
test_case: The test case that was tested
|
|
286
|
+
"""
|
|
287
|
+
if (
|
|
288
|
+
self.also_interesting_code is not None
|
|
289
|
+
and exit_code == self.also_interesting_code
|
|
290
|
+
and self.history_manager is not None
|
|
291
|
+
):
|
|
292
|
+
test_case_bytes = self._get_test_case_bytes(test_case)
|
|
293
|
+
output = self._get_last_captured_output()
|
|
294
|
+
self.history_manager.record_also_interesting(test_case_bytes, output)
|
|
295
|
+
|
|
214
296
|
@abstractmethod
|
|
215
297
|
def new_reducer(self, problem: ReductionProblem[TestCase]) -> Reducer[TestCase]: ...
|
|
216
298
|
|
|
299
|
+
@abstractmethod
|
|
300
|
+
def _get_initial_bytes(self) -> bytes:
|
|
301
|
+
"""Get the initial test case as bytes for history recording."""
|
|
302
|
+
...
|
|
303
|
+
|
|
304
|
+
@abstractmethod
|
|
305
|
+
def _get_test_case_bytes(self, test_case: TestCase) -> bytes:
|
|
306
|
+
"""Convert a test case to bytes for history recording."""
|
|
307
|
+
...
|
|
308
|
+
|
|
217
309
|
@abstractmethod
|
|
218
310
|
async def write_test_case_to_file_impl(self, working: str, test_case: TestCase): ...
|
|
219
311
|
|
|
@@ -223,19 +315,9 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
223
315
|
async def run_script_on_file(
|
|
224
316
|
self, working: str, cwd: str, debug: bool = False
|
|
225
317
|
) -> int:
|
|
226
|
-
# Lazy import to avoid circular dependency
|
|
227
|
-
if self._interrupt_wait_and_kill is None:
|
|
228
|
-
from shrinkray.process import interrupt_wait_and_kill
|
|
229
|
-
|
|
230
|
-
self._interrupt_wait_and_kill = interrupt_wait_and_kill
|
|
231
|
-
if self._InputType is None:
|
|
232
|
-
from shrinkray.cli import InputType
|
|
233
|
-
|
|
234
|
-
self._InputType = InputType
|
|
235
|
-
|
|
236
318
|
if not os.path.exists(working):
|
|
237
319
|
raise ValueError(f"No such file {working}")
|
|
238
|
-
if self.input_type.enabled(
|
|
320
|
+
if self.input_type.enabled(InputType.arg):
|
|
239
321
|
command = self.test + [working]
|
|
240
322
|
else:
|
|
241
323
|
command = self.test
|
|
@@ -246,9 +328,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
246
328
|
"cwd": cwd,
|
|
247
329
|
"check": False,
|
|
248
330
|
}
|
|
249
|
-
if self.input_type.enabled(
|
|
250
|
-
working
|
|
251
|
-
):
|
|
331
|
+
if self.input_type.enabled(InputType.stdin) and not os.path.isdir(working):
|
|
252
332
|
with open(working, "rb") as i:
|
|
253
333
|
kwargs["stdin"] = i.read()
|
|
254
334
|
else:
|
|
@@ -291,6 +371,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
291
371
|
# Determine output handling
|
|
292
372
|
test_id: int | None = None
|
|
293
373
|
output_file_handle = None
|
|
374
|
+
output_path: str | None = None
|
|
294
375
|
exit_code: int | None = None # Track for output manager
|
|
295
376
|
|
|
296
377
|
if self.output_manager is not None:
|
|
@@ -337,7 +418,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
337
418
|
|
|
338
419
|
if sp.returncode is None:
|
|
339
420
|
# Process didn't terminate before timeout - kill it
|
|
340
|
-
await
|
|
421
|
+
await interrupt_wait_and_kill(sp)
|
|
341
422
|
|
|
342
423
|
# Check for timeout violation (only when timeout is explicitly set)
|
|
343
424
|
if (
|
|
@@ -364,21 +445,25 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
364
445
|
|
|
365
446
|
return result
|
|
366
447
|
finally:
|
|
367
|
-
# Clean up output file handle and
|
|
448
|
+
# Clean up output file handle and capture output immediately
|
|
368
449
|
if output_file_handle is not None:
|
|
369
450
|
output_file_handle.close()
|
|
451
|
+
# Read the output file NOW, before any other test can interfere
|
|
452
|
+
# This avoids race conditions where get_current_output() returns
|
|
453
|
+
# a different test's partial output
|
|
454
|
+
# output_path must be set since it's assigned with output_file_handle
|
|
455
|
+
assert output_path is not None
|
|
456
|
+
try:
|
|
457
|
+
with open(output_path, "rb") as f:
|
|
458
|
+
self._last_test_output = f.read()
|
|
459
|
+
except OSError:
|
|
460
|
+
self._last_test_output = None
|
|
370
461
|
if test_id is not None and self.output_manager is not None:
|
|
371
462
|
self.output_manager.mark_completed(test_id, exit_code or 0)
|
|
372
463
|
|
|
373
464
|
async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
|
|
374
|
-
# Lazy import
|
|
375
|
-
if self._InputType is None:
|
|
376
|
-
from shrinkray.cli import InputType
|
|
377
|
-
|
|
378
|
-
self._InputType = InputType
|
|
379
|
-
|
|
380
465
|
if self.in_place:
|
|
381
|
-
if self.input_type ==
|
|
466
|
+
if self.input_type == InputType.basename:
|
|
382
467
|
working = self.filename
|
|
383
468
|
await self.write_test_case_to_file(working, test_case)
|
|
384
469
|
|
|
@@ -430,7 +515,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
430
515
|
@property
|
|
431
516
|
def reducer(self):
|
|
432
517
|
try:
|
|
433
|
-
return self.
|
|
518
|
+
return self._cached_reducer
|
|
434
519
|
except AttributeError:
|
|
435
520
|
pass
|
|
436
521
|
|
|
@@ -458,8 +543,20 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
458
543
|
async with write_lock:
|
|
459
544
|
await self.write_test_case_to_file(self.filename, test_case)
|
|
460
545
|
|
|
461
|
-
|
|
462
|
-
|
|
546
|
+
# Initialize history and register callback if enabled
|
|
547
|
+
if self.history_manager is not None:
|
|
548
|
+
self._initialize_history_manager()
|
|
549
|
+
|
|
550
|
+
@problem.on_reduce
|
|
551
|
+
async def record_history(test_case: TestCase):
|
|
552
|
+
test_case_bytes = self._get_test_case_bytes(test_case)
|
|
553
|
+
# Use output captured at is_interesting time to avoid race conditions
|
|
554
|
+
output = self._successful_outputs.pop(test_case_bytes, None)
|
|
555
|
+
assert self.history_manager is not None
|
|
556
|
+
self.history_manager.record_reduction(test_case_bytes, output)
|
|
557
|
+
|
|
558
|
+
self._cached_reducer = self.new_reducer(problem)
|
|
559
|
+
return self._cached_reducer
|
|
463
560
|
|
|
464
561
|
@property
|
|
465
562
|
def extra_problem_kwargs(self):
|
|
@@ -470,10 +567,65 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
470
567
|
return self.reducer.target
|
|
471
568
|
|
|
472
569
|
async def is_interesting(self, test_case: TestCase) -> bool:
|
|
570
|
+
# Check exclusion set first (for restart-from-point feature)
|
|
571
|
+
if self.excluded_test_cases is not None:
|
|
572
|
+
test_case_bytes = self._get_test_case_bytes(test_case)
|
|
573
|
+
if test_case_bytes in self.excluded_test_cases:
|
|
574
|
+
return False
|
|
575
|
+
|
|
473
576
|
if self.first_call_time is None:
|
|
474
577
|
self.first_call_time = time.time()
|
|
475
578
|
async with self.is_interesting_limiter:
|
|
476
|
-
|
|
579
|
+
exit_code = await self.run_for_exit_code(test_case)
|
|
580
|
+
self._check_also_interesting(exit_code, test_case)
|
|
581
|
+
if exit_code == 0:
|
|
582
|
+
# Capture output now while still in the limiter to avoid race conditions
|
|
583
|
+
# where another test starts and overwrites the "current" output
|
|
584
|
+
test_case_bytes = self._get_test_case_bytes(test_case)
|
|
585
|
+
output = self._get_last_captured_output()
|
|
586
|
+
if output is not None:
|
|
587
|
+
self._successful_outputs[test_case_bytes] = output
|
|
588
|
+
return True
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
def reset_for_restart(self, new_initial: bytes, excluded: set[bytes]) -> None:
|
|
592
|
+
"""Reset state for restart from a history point.
|
|
593
|
+
|
|
594
|
+
This clears the cached reducer so it will be recreated with the new
|
|
595
|
+
initial value, and sets the exclusion set to reject previously
|
|
596
|
+
reduced values.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
new_initial: The new initial test case content
|
|
600
|
+
excluded: Set of test cases to reject as uninteresting
|
|
601
|
+
"""
|
|
602
|
+
self.excluded_test_cases = excluded
|
|
603
|
+
# Clear cached reducer so it will be recreated on next access
|
|
604
|
+
try:
|
|
605
|
+
del self._cached_reducer
|
|
606
|
+
except AttributeError:
|
|
607
|
+
pass
|
|
608
|
+
# Clear stored successful outputs (no longer relevant after restart)
|
|
609
|
+
self._successful_outputs.clear()
|
|
610
|
+
# Reset initial_exit_code - the new initial is known to be interesting
|
|
611
|
+
# (it came from history) so its exit code was 0
|
|
612
|
+
self.initial_exit_code = 0
|
|
613
|
+
# Update initial (implementation depends on subclass)
|
|
614
|
+
self._set_initial_for_restart(new_initial)
|
|
615
|
+
|
|
616
|
+
@abstractmethod
|
|
617
|
+
def _set_initial_for_restart(self, content: bytes) -> None:
|
|
618
|
+
"""Set the initial test case for restart. Subclasses implement."""
|
|
619
|
+
...
|
|
620
|
+
|
|
621
|
+
def _initialize_history_manager(self) -> None:
|
|
622
|
+
"""Initialize the history manager. Subclasses can override for different modes."""
|
|
623
|
+
assert self.history_manager is not None
|
|
624
|
+
self.history_manager.initialize(
|
|
625
|
+
self._get_initial_bytes(),
|
|
626
|
+
self.test,
|
|
627
|
+
self.filename,
|
|
628
|
+
)
|
|
477
629
|
|
|
478
630
|
@property
|
|
479
631
|
def parallel_tasks_running(self) -> int:
|
|
@@ -622,12 +774,16 @@ class ShrinkRayStateSingleFile(ShrinkRayState[bytes]):
|
|
|
622
774
|
def new_reducer(self, problem: ReductionProblem[bytes]) -> Reducer[bytes]:
|
|
623
775
|
return ShrinkRay(problem, clang_delta=self.clang_delta_executable)
|
|
624
776
|
|
|
625
|
-
def
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
777
|
+
def _get_initial_bytes(self) -> bytes:
|
|
778
|
+
return self.initial
|
|
779
|
+
|
|
780
|
+
def _get_test_case_bytes(self, test_case: bytes) -> bytes:
|
|
781
|
+
return test_case
|
|
630
782
|
|
|
783
|
+
def _set_initial_for_restart(self, content: bytes) -> None:
|
|
784
|
+
self.initial = content
|
|
785
|
+
|
|
786
|
+
def setup_formatter(self):
|
|
631
787
|
if self.formatter.lower() == "none":
|
|
632
788
|
|
|
633
789
|
async def format_data(test_case: bytes) -> bytes | None:
|
|
@@ -678,8 +834,24 @@ class ShrinkRayStateSingleFile(ShrinkRayState[bytes]):
|
|
|
678
834
|
await o.write(test_case)
|
|
679
835
|
|
|
680
836
|
async def is_interesting(self, test_case: bytes) -> bool:
|
|
837
|
+
# Check exclusion set first (for restart-from-point feature)
|
|
838
|
+
if (
|
|
839
|
+
self.excluded_test_cases is not None
|
|
840
|
+
and test_case in self.excluded_test_cases
|
|
841
|
+
):
|
|
842
|
+
return False
|
|
843
|
+
|
|
681
844
|
async with self.is_interesting_limiter:
|
|
682
|
-
|
|
845
|
+
exit_code = await self.run_for_exit_code(test_case)
|
|
846
|
+
self._check_also_interesting(exit_code, test_case)
|
|
847
|
+
if exit_code == 0:
|
|
848
|
+
# Capture output now while still in the limiter to avoid race conditions
|
|
849
|
+
# where another test starts and overwrites the "current" output
|
|
850
|
+
output = self._get_last_captured_output()
|
|
851
|
+
if output is not None:
|
|
852
|
+
self._successful_outputs[test_case] = output
|
|
853
|
+
return True
|
|
854
|
+
return False
|
|
683
855
|
|
|
684
856
|
async def print_exit_message(self, problem):
|
|
685
857
|
formatting_increase = 0
|
|
@@ -727,6 +899,11 @@ class ShrinkRayStateSingleFile(ShrinkRayState[bytes]):
|
|
|
727
899
|
class ShrinkRayDirectoryState(ShrinkRayState[dict[str, bytes]]):
|
|
728
900
|
def setup_formatter(self): ...
|
|
729
901
|
|
|
902
|
+
@property
|
|
903
|
+
def is_directory_mode(self) -> bool:
|
|
904
|
+
"""Whether this state manages directory test cases."""
|
|
905
|
+
return True
|
|
906
|
+
|
|
730
907
|
@property
|
|
731
908
|
def extra_problem_kwargs(self) -> dict[str, Any]:
|
|
732
909
|
return {
|
|
@@ -740,6 +917,35 @@ class ShrinkRayDirectoryState(ShrinkRayState[dict[str, bytes]]):
|
|
|
740
917
|
target=problem, clang_delta=self.clang_delta_executable
|
|
741
918
|
)
|
|
742
919
|
|
|
920
|
+
def _get_initial_bytes(self) -> bytes:
|
|
921
|
+
# Serialize directory content for history recording
|
|
922
|
+
return self._serialize_directory(self.initial)
|
|
923
|
+
|
|
924
|
+
def _get_test_case_bytes(self, test_case: dict[str, bytes]) -> bytes:
|
|
925
|
+
# Serialize directory content for comparison/exclusion
|
|
926
|
+
return self._serialize_directory(test_case)
|
|
927
|
+
|
|
928
|
+
def _set_initial_for_restart(self, content: bytes) -> None:
|
|
929
|
+
# Deserialize and update initial directory content
|
|
930
|
+
self.initial = self._deserialize_directory(content)
|
|
931
|
+
|
|
932
|
+
def _initialize_history_manager(self) -> None:
|
|
933
|
+
"""Initialize the history manager in directory mode."""
|
|
934
|
+
assert self.history_manager is not None
|
|
935
|
+
self.history_manager.initialize_directory(
|
|
936
|
+
self.initial,
|
|
937
|
+
self.test,
|
|
938
|
+
self.filename,
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
@staticmethod
|
|
942
|
+
def _serialize_directory(content: dict[str, bytes]) -> bytes:
|
|
943
|
+
return serialize_directory(content)
|
|
944
|
+
|
|
945
|
+
@staticmethod
|
|
946
|
+
def _deserialize_directory(data: bytes) -> dict[str, bytes]:
|
|
947
|
+
return deserialize_directory(data)
|
|
948
|
+
|
|
743
949
|
async def write_test_case_to_file_impl(
|
|
744
950
|
self, working: str, test_case: dict[str, bytes]
|
|
745
951
|
):
|
shrinkray/subprocess/client.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"""Client for communicating with the reducer subprocess."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import os
|
|
4
5
|
import sys
|
|
6
|
+
import tempfile
|
|
5
7
|
import traceback
|
|
6
8
|
import uuid
|
|
7
9
|
from collections.abc import AsyncGenerator
|
|
8
|
-
from typing import Any
|
|
10
|
+
from typing import IO, Any
|
|
9
11
|
|
|
10
12
|
from shrinkray.subprocess.protocol import (
|
|
11
13
|
ProgressUpdate,
|
|
@@ -27,18 +29,28 @@ class SubprocessClient:
|
|
|
27
29
|
self._completed = False
|
|
28
30
|
self._error_message: str | None = None
|
|
29
31
|
self._debug_mode = debug_mode
|
|
32
|
+
self._stderr_log_file: IO[str] | None = None
|
|
33
|
+
self._stderr_log_path: str | None = None
|
|
30
34
|
|
|
31
35
|
async def start(self) -> None:
|
|
32
36
|
"""Launch the subprocess."""
|
|
33
|
-
#
|
|
34
|
-
#
|
|
37
|
+
# Log subprocess stderr to a temp file for debugging.
|
|
38
|
+
# This captures bootstrap errors before history is set up.
|
|
39
|
+
# Once the worker starts with history enabled, it redirects its own
|
|
40
|
+
# stderr to the per-run history directory.
|
|
41
|
+
fd, self._stderr_log_path = tempfile.mkstemp(
|
|
42
|
+
prefix="shrinkray-stderr-",
|
|
43
|
+
suffix=".log",
|
|
44
|
+
)
|
|
45
|
+
self._stderr_log_file = os.fdopen(fd, "w", encoding="utf-8")
|
|
46
|
+
|
|
35
47
|
self._process = await asyncio.create_subprocess_exec(
|
|
36
48
|
sys.executable,
|
|
37
49
|
"-m",
|
|
38
50
|
"shrinkray.subprocess.worker",
|
|
39
51
|
stdin=asyncio.subprocess.PIPE,
|
|
40
52
|
stdout=asyncio.subprocess.PIPE,
|
|
41
|
-
stderr=
|
|
53
|
+
stderr=self._stderr_log_file,
|
|
42
54
|
)
|
|
43
55
|
self._reader_task = asyncio.create_task(self._read_output())
|
|
44
56
|
|
|
@@ -137,6 +149,8 @@ class SubprocessClient:
|
|
|
137
149
|
clang_delta: str = "",
|
|
138
150
|
trivial_is_error: bool = True,
|
|
139
151
|
skip_validation: bool = False,
|
|
152
|
+
history_enabled: bool = True,
|
|
153
|
+
also_interesting_code: int | None = None,
|
|
140
154
|
) -> Response:
|
|
141
155
|
"""Start the reduction process."""
|
|
142
156
|
params: dict[str, Any] = {
|
|
@@ -151,6 +165,8 @@ class SubprocessClient:
|
|
|
151
165
|
"clang_delta": clang_delta,
|
|
152
166
|
"trivial_is_error": trivial_is_error,
|
|
153
167
|
"skip_validation": skip_validation,
|
|
168
|
+
"history_enabled": history_enabled,
|
|
169
|
+
"also_interesting_code": also_interesting_code,
|
|
154
170
|
}
|
|
155
171
|
if parallelism is not None:
|
|
156
172
|
params["parallelism"] = parallelism
|
|
@@ -203,6 +219,27 @@ class SubprocessClient:
|
|
|
203
219
|
traceback.print_exc()
|
|
204
220
|
return Response(id="", error="Failed to skip pass")
|
|
205
221
|
|
|
222
|
+
async def restart_from(self, reduction_number: int) -> Response:
|
|
223
|
+
"""Restart reduction from a specific history point.
|
|
224
|
+
|
|
225
|
+
This moves all reductions after the specified point to also-interesting,
|
|
226
|
+
resets the current test case to that point, and continues reduction
|
|
227
|
+
from there, rejecting previously reduced values.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
reduction_number: The reduction entry number to restart from
|
|
231
|
+
(e.g., 3 for reduction 0003)
|
|
232
|
+
"""
|
|
233
|
+
if self._completed:
|
|
234
|
+
return Response(id="", error="Reduction already completed")
|
|
235
|
+
try:
|
|
236
|
+
return await self.send_command(
|
|
237
|
+
"restart_from", {"reduction_number": reduction_number}
|
|
238
|
+
)
|
|
239
|
+
except Exception:
|
|
240
|
+
traceback.print_exc()
|
|
241
|
+
return Response(id="", error="Failed to send restart command")
|
|
242
|
+
|
|
206
243
|
async def get_progress_updates(self) -> AsyncGenerator[ProgressUpdate, None]:
|
|
207
244
|
"""Yield progress updates as they arrive."""
|
|
208
245
|
while not self._completed:
|
|
@@ -248,6 +285,18 @@ class SubprocessClient:
|
|
|
248
285
|
except ProcessLookupError:
|
|
249
286
|
pass # Process already exited
|
|
250
287
|
|
|
288
|
+
# Close and remove the stderr log file
|
|
289
|
+
if self._stderr_log_file is not None:
|
|
290
|
+
try:
|
|
291
|
+
self._stderr_log_file.close()
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
if self._stderr_log_path is not None:
|
|
295
|
+
try:
|
|
296
|
+
os.unlink(self._stderr_log_path)
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
|
|
251
300
|
async def __aenter__(self) -> "SubprocessClient":
|
|
252
301
|
await self.start()
|
|
253
302
|
return self
|
shrinkray/subprocess/protocol.py
CHANGED
|
@@ -64,6 +64,10 @@ class ProgressUpdate:
|
|
|
64
64
|
# New size history entries since last update: list of (runtime_seconds, size)
|
|
65
65
|
# Client should accumulate these over time
|
|
66
66
|
new_size_history: list[tuple[float, int]] = field(default_factory=list)
|
|
67
|
+
# History directory path (for browsing reductions/also-interesting)
|
|
68
|
+
history_dir: str | None = None
|
|
69
|
+
# Target file basename (for reading history files)
|
|
70
|
+
target_basename: str = ""
|
|
67
71
|
|
|
68
72
|
|
|
69
73
|
@dataclass
|
|
@@ -127,6 +131,8 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
|
|
|
127
131
|
"active_test_id": msg.active_test_id,
|
|
128
132
|
"last_test_return_code": msg.last_test_return_code,
|
|
129
133
|
"new_size_history": msg.new_size_history,
|
|
134
|
+
"history_dir": msg.history_dir,
|
|
135
|
+
"target_basename": msg.target_basename,
|
|
130
136
|
},
|
|
131
137
|
}
|
|
132
138
|
else:
|
|
@@ -178,6 +184,8 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
|
|
|
178
184
|
active_test_id=d.get("active_test_id"),
|
|
179
185
|
last_test_return_code=d.get("last_test_return_code"),
|
|
180
186
|
new_size_history=[tuple(x) for x in d.get("new_size_history", [])],
|
|
187
|
+
history_dir=d.get("history_dir"),
|
|
188
|
+
target_basename=d.get("target_basename", ""),
|
|
181
189
|
)
|
|
182
190
|
|
|
183
191
|
# Check for response (has "result" or "error" field)
|