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/__main__.py +48 -1
- shrinkray/history.py +446 -0
- shrinkray/state.py +289 -88
- shrinkray/subprocess/client.py +53 -4
- shrinkray/subprocess/protocol.py +18 -1
- shrinkray/subprocess/worker.py +253 -42
- shrinkray/tui.py +1124 -43
- {shrinkray-25.12.28.0.dist-info → shrinkray-26.1.1.0.dist-info}/METADATA +7 -2
- {shrinkray-25.12.28.0.dist-info → shrinkray-26.1.1.0.dist-info}/RECORD +13 -12
- {shrinkray-25.12.28.0.dist-info → shrinkray-26.1.1.0.dist-info}/WHEEL +0 -0
- {shrinkray-25.12.28.0.dist-info → shrinkray-26.1.1.0.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.28.0.dist-info → shrinkray-26.1.1.0.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.28.0.dist-info → shrinkray-26.1.1.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
|
|
|
@@ -57,7 +65,7 @@ def compute_dynamic_timeout(runtime: float) -> float:
|
|
|
57
65
|
|
|
58
66
|
|
|
59
67
|
@define
|
|
60
|
-
class
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
133
|
+
def get_current_output(self) -> tuple[str | None, int | None, int | None]:
|
|
134
|
+
"""Get the current output to display.
|
|
131
135
|
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
136
|
-
recently completed test output for min_display_seconds,
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
212
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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 ==
|
|
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.
|
|
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
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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
|
):
|
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
|