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/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
- # Optional output manager for capturing test output (TUI mode)
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(self._InputType.arg):
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(self._InputType.stdin) and not os.path.isdir(
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 self._interrupt_wait_and_kill(sp)
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 mark test as completed
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 == self._InputType.basename:
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.__reducer
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
- self.__reducer = self.new_reducer(problem)
462
- return self.__reducer
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
- return await self.run_for_exit_code(test_case) == 0
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 setup_formatter(self):
626
- from shrinkray.formatting import (
627
- default_reformat_data,
628
- determine_formatter_command,
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
- return await self.run_for_exit_code(test_case) == 0
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
  ):
@@ -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
- # In debug mode, inherit stderr so interestingness test output
34
- # goes directly to the parent process's stderr
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=sys.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
@@ -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)