shrinkray 25.12.28.0__py3-none-any.whl → 26.1.1.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
 
@@ -57,7 +65,7 @@ def compute_dynamic_timeout(runtime: float) -> float:
57
65
 
58
66
 
59
67
  @define
60
- class TestOutputManager:
68
+ class OutputCaptureManager:
61
69
  """Manages temporary files for test output capture.
62
70
 
63
71
  Allocates unique files for each test's stdout/stderr output,
@@ -74,7 +82,8 @@ class TestOutputManager:
74
82
 
75
83
  _sequence: int = 0
76
84
  _active_outputs: dict[int, str] = {}
77
- _completed_outputs: deque[tuple[int, str, float]] = deque()
85
+ # Completed outputs: (test_id, file_path, completion_time, return_code)
86
+ _completed_outputs: deque[tuple[int, str, float, int]] = deque()
78
87
 
79
88
  def __attrs_post_init__(self) -> None:
80
89
  # Initialize mutable defaults
@@ -89,11 +98,13 @@ class TestOutputManager:
89
98
  self._active_outputs[test_id] = file_path
90
99
  return test_id, file_path
91
100
 
92
- def mark_completed(self, test_id: int) -> None:
101
+ def mark_completed(self, test_id: int, return_code: int = 0) -> None:
93
102
  """Mark a test as completed and move to completed queue."""
94
103
  if test_id in self._active_outputs:
95
104
  file_path = self._active_outputs.pop(test_id)
96
- self._completed_outputs.append((test_id, file_path, time.time()))
105
+ self._completed_outputs.append(
106
+ (test_id, file_path, time.time(), return_code)
107
+ )
97
108
  self._cleanup_old_files()
98
109
 
99
110
  def _cleanup_old_files(self) -> None:
@@ -104,65 +115,55 @@ class TestOutputManager:
104
115
  self._completed_outputs
105
116
  and now - self._completed_outputs[0][2] > self.max_age_seconds
106
117
  ):
107
- _, file_path, _ = self._completed_outputs.popleft()
118
+ _, file_path, _, _ = self._completed_outputs.popleft()
108
119
  self._safe_delete(file_path)
109
120
  # Remove excess files beyond max_files
110
121
  while len(self._completed_outputs) > self.max_files:
111
- _, file_path, _ = self._completed_outputs.popleft()
122
+ _, file_path, _, _ = self._completed_outputs.popleft()
112
123
  self._safe_delete(file_path)
113
124
 
114
- def _should_show_completed(self) -> tuple[int, str] | None:
115
- """Check if we should show a completed test's output.
116
-
117
- Note: This method is only called when there are no active tests.
118
- It returns the completed test info if within the display window
119
- (min_display_seconds + grace_period).
120
- """
121
- if not self._completed_outputs:
122
- return None
123
- test_id, file_path, completion_time = self._completed_outputs[-1]
124
- elapsed = time.time() - completion_time
125
-
126
- # Show completed test during the full display window
127
- if elapsed < self.min_display_seconds + self.grace_period_seconds:
128
- return test_id, file_path
125
+ @staticmethod
126
+ def _file_has_content(path: str) -> bool:
127
+ """Check if a file exists and has non-zero size."""
128
+ try:
129
+ return os.path.getsize(path) > 0
130
+ except OSError:
131
+ return False
129
132
 
130
- return None
133
+ def get_current_output(self) -> tuple[str | None, int | None, int | None]:
134
+ """Get the current output to display.
131
135
 
132
- def get_current_output_path(self) -> str | None:
133
- """Get the most relevant output file path.
136
+ Returns (file_path, test_id, return_code) where:
137
+ - file_path: path to the output file to display
138
+ - test_id: the test ID (for display in header)
139
+ - return_code: the return code (None if test is still running)
134
140
 
135
- Active tests always take priority. If no active test, shows
136
- recently completed test output for min_display_seconds, plus
137
- an additional grace_period if no new test has started.
141
+ Active tests take priority only if they have produced output.
142
+ Otherwise, shows recently completed test output for min_display_seconds,
143
+ plus an additional grace_period if no new test has started.
138
144
  """
139
- # Active tests always take priority
145
+ # Active tests take priority only if they have content
140
146
  if self._active_outputs:
141
147
  max_id = max(self._active_outputs.keys())
142
- return self._active_outputs[max_id]
143
- # Then check for recently completed test that should stay visible
144
- recent = self._should_show_completed()
145
- if recent is not None:
146
- return recent[1]
147
- # Fall back to most recent completed (even if past display window)
148
+ active_path = self._active_outputs[max_id]
149
+ if self._file_has_content(active_path):
150
+ # Active test with output - no return code yet
151
+ return active_path, max_id, None
152
+ # Active test has no output yet - fall through to show previous output
153
+
154
+ # Check for recently completed test that should stay visible,
155
+ # or fall back to most recent completed (even if past display window)
148
156
  if self._completed_outputs:
149
- return self._completed_outputs[-1][1]
150
- return None
151
-
152
- def get_active_test_id(self) -> int | None:
153
- """Get the currently running test ID, if any.
157
+ test_id, file_path, _, return_code = self._completed_outputs[-1]
158
+ return file_path, test_id, return_code
154
159
 
155
- Returns the active test ID if one is running, None otherwise.
156
- """
157
- if self._active_outputs:
158
- return max(self._active_outputs.keys())
159
- return None
160
+ return None, None, None
160
161
 
161
162
  def cleanup_all(self) -> None:
162
163
  """Clean up all output files (called on shutdown)."""
163
164
  for file_path in self._active_outputs.values():
164
165
  self._safe_delete(file_path)
165
- for _, file_path, _ in self._completed_outputs:
166
+ for _, file_path, _, _ in self._completed_outputs:
166
167
  self._safe_delete(file_path)
167
168
  self._active_outputs.clear()
168
169
  self._completed_outputs.clear()
@@ -198,29 +199,113 @@ class ShrinkRayState[TestCase](ABC):
198
199
 
199
200
  first_call_time: float | None = None
200
201
 
201
- # Lazy imports to break circular dependencies:
202
- # - shrinkray.process imports from shrinkray.work which imports from here
203
- # - shrinkray.cli imports from here for state configuration
204
- # These are cached after first import for performance.
205
- _interrupt_wait_and_kill: Any = None
206
- _InputType: Any = None # InputType enum from shrinkray.cli
207
-
208
202
  # Stores the output from the last debug run
209
203
  _last_debug_output: str = ""
210
204
 
211
- # Optional output manager for capturing test output (TUI mode)
212
- output_manager: TestOutputManager | None = None
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)
211
+ output_manager: OutputCaptureManager | None = None
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] = {}
213
232
 
214
233
  def __attrs_post_init__(self):
215
234
  self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
235
+ self._successful_outputs = {} # Initialize mutable default
216
236
  self.setup_formatter()
237
+ self._setup_history()
217
238
 
218
239
  @abstractmethod
219
240
  def setup_formatter(self): ...
220
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
+
221
296
  @abstractmethod
222
297
  def new_reducer(self, problem: ReductionProblem[TestCase]) -> Reducer[TestCase]: ...
223
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
+
224
309
  @abstractmethod
225
310
  async def write_test_case_to_file_impl(self, working: str, test_case: TestCase): ...
226
311
 
@@ -230,19 +315,9 @@ class ShrinkRayState[TestCase](ABC):
230
315
  async def run_script_on_file(
231
316
  self, working: str, cwd: str, debug: bool = False
232
317
  ) -> int:
233
- # Lazy import to avoid circular dependency
234
- if self._interrupt_wait_and_kill is None:
235
- from shrinkray.process import interrupt_wait_and_kill
236
-
237
- self._interrupt_wait_and_kill = interrupt_wait_and_kill
238
- if self._InputType is None:
239
- from shrinkray.cli import InputType
240
-
241
- self._InputType = InputType
242
-
243
318
  if not os.path.exists(working):
244
319
  raise ValueError(f"No such file {working}")
245
- if self.input_type.enabled(self._InputType.arg):
320
+ if self.input_type.enabled(InputType.arg):
246
321
  command = self.test + [working]
247
322
  else:
248
323
  command = self.test
@@ -253,9 +328,7 @@ class ShrinkRayState[TestCase](ABC):
253
328
  "cwd": cwd,
254
329
  "check": False,
255
330
  }
256
- if self.input_type.enabled(self._InputType.stdin) and not os.path.isdir(
257
- working
258
- ):
331
+ if self.input_type.enabled(InputType.stdin) and not os.path.isdir(working):
259
332
  with open(working, "rb") as i:
260
333
  kwargs["stdin"] = i.read()
261
334
  else:
@@ -298,6 +371,8 @@ class ShrinkRayState[TestCase](ABC):
298
371
  # Determine output handling
299
372
  test_id: int | None = None
300
373
  output_file_handle = None
374
+ output_path: str | None = None
375
+ exit_code: int | None = None # Track for output manager
301
376
 
302
377
  if self.output_manager is not None:
303
378
  # Capture output to a file for TUI display
@@ -343,7 +418,7 @@ class ShrinkRayState[TestCase](ABC):
343
418
 
344
419
  if sp.returncode is None:
345
420
  # Process didn't terminate before timeout - kill it
346
- await self._interrupt_wait_and_kill(sp)
421
+ await interrupt_wait_and_kill(sp)
347
422
 
348
423
  # Check for timeout violation (only when timeout is explicitly set)
349
424
  if (
@@ -366,24 +441,29 @@ class ShrinkRayState[TestCase](ABC):
366
441
 
367
442
  result: int | None = sp.returncode
368
443
  assert result is not None
444
+ exit_code = result
369
445
 
370
446
  return result
371
447
  finally:
372
- # Clean up output file handle and mark test as completed
448
+ # Clean up output file handle and capture output immediately
373
449
  if output_file_handle is not None:
374
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
375
461
  if test_id is not None and self.output_manager is not None:
376
- self.output_manager.mark_completed(test_id)
462
+ self.output_manager.mark_completed(test_id, exit_code or 0)
377
463
 
378
464
  async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
379
- # Lazy import
380
- if self._InputType is None:
381
- from shrinkray.cli import InputType
382
-
383
- self._InputType = InputType
384
-
385
465
  if self.in_place:
386
- if self.input_type == self._InputType.basename:
466
+ if self.input_type == InputType.basename:
387
467
  working = self.filename
388
468
  await self.write_test_case_to_file(working, test_case)
389
469
 
@@ -435,7 +515,7 @@ class ShrinkRayState[TestCase](ABC):
435
515
  @property
436
516
  def reducer(self):
437
517
  try:
438
- return self.__reducer
518
+ return self._cached_reducer
439
519
  except AttributeError:
440
520
  pass
441
521
 
@@ -463,8 +543,20 @@ class ShrinkRayState[TestCase](ABC):
463
543
  async with write_lock:
464
544
  await self.write_test_case_to_file(self.filename, test_case)
465
545
 
466
- self.__reducer = self.new_reducer(problem)
467
- 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
468
560
 
469
561
  @property
470
562
  def extra_problem_kwargs(self):
@@ -475,10 +567,65 @@ class ShrinkRayState[TestCase](ABC):
475
567
  return self.reducer.target
476
568
 
477
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
+
478
576
  if self.first_call_time is None:
479
577
  self.first_call_time = time.time()
480
578
  async with self.is_interesting_limiter:
481
- 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
+ )
482
629
 
483
630
  @property
484
631
  def parallel_tasks_running(self) -> int:
@@ -627,12 +774,16 @@ class ShrinkRayStateSingleFile(ShrinkRayState[bytes]):
627
774
  def new_reducer(self, problem: ReductionProblem[bytes]) -> Reducer[bytes]:
628
775
  return ShrinkRay(problem, clang_delta=self.clang_delta_executable)
629
776
 
630
- def setup_formatter(self):
631
- from shrinkray.formatting import (
632
- default_reformat_data,
633
- determine_formatter_command,
634
- )
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
635
782
 
783
+ def _set_initial_for_restart(self, content: bytes) -> None:
784
+ self.initial = content
785
+
786
+ def setup_formatter(self):
636
787
  if self.formatter.lower() == "none":
637
788
 
638
789
  async def format_data(test_case: bytes) -> bytes | None:
@@ -683,8 +834,24 @@ class ShrinkRayStateSingleFile(ShrinkRayState[bytes]):
683
834
  await o.write(test_case)
684
835
 
685
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
+
686
844
  async with self.is_interesting_limiter:
687
- 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
688
855
 
689
856
  async def print_exit_message(self, problem):
690
857
  formatting_increase = 0
@@ -732,6 +899,11 @@ class ShrinkRayStateSingleFile(ShrinkRayState[bytes]):
732
899
  class ShrinkRayDirectoryState(ShrinkRayState[dict[str, bytes]]):
733
900
  def setup_formatter(self): ...
734
901
 
902
+ @property
903
+ def is_directory_mode(self) -> bool:
904
+ """Whether this state manages directory test cases."""
905
+ return True
906
+
735
907
  @property
736
908
  def extra_problem_kwargs(self) -> dict[str, Any]:
737
909
  return {
@@ -745,6 +917,35 @@ class ShrinkRayDirectoryState(ShrinkRayState[dict[str, bytes]]):
745
917
  target=problem, clang_delta=self.clang_delta_executable
746
918
  )
747
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
+
748
949
  async def write_test_case_to_file_impl(
749
950
  self, working: str, test_case: dict[str, bytes]
750
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