shrinkray 25.12.27.1__py3-none-any.whl → 25.12.27.3__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 +25 -11
- shrinkray/passes/bytes.py +8 -7
- shrinkray/passes/definitions.py +3 -67
- shrinkray/passes/genericlanguages.py +14 -10
- shrinkray/passes/json.py +2 -2
- shrinkray/passes/sat.py +2 -7
- shrinkray/problem.py +257 -11
- shrinkray/reducer.py +9 -2
- shrinkray/state.py +199 -67
- shrinkray/subprocess/client.py +2 -0
- shrinkray/subprocess/protocol.py +8 -0
- shrinkray/subprocess/worker.py +67 -17
- shrinkray/tui.py +114 -92
- shrinkray/validation.py +403 -0
- {shrinkray-25.12.27.1.dist-info → shrinkray-25.12.27.3.dist-info}/METADATA +1 -28
- shrinkray-25.12.27.3.dist-info/RECORD +34 -0
- shrinkray-25.12.27.1.dist-info/RECORD +0 -33
- {shrinkray-25.12.27.1.dist-info → shrinkray-25.12.27.3.dist-info}/WHEEL +0 -0
- {shrinkray-25.12.27.1.dist-info → shrinkray-25.12.27.3.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.27.1.dist-info → shrinkray-25.12.27.3.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.27.1.dist-info → shrinkray-25.12.27.3.dist-info}/top_level.txt +0 -0
shrinkray/state.py
CHANGED
|
@@ -8,9 +8,10 @@ import subprocess
|
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
10
|
from abc import ABC, abstractmethod
|
|
11
|
+
from collections import deque
|
|
11
12
|
from datetime import timedelta
|
|
12
13
|
from tempfile import TemporaryDirectory
|
|
13
|
-
from typing import Any
|
|
14
|
+
from typing import Any, TypeVar
|
|
14
15
|
|
|
15
16
|
import humanize
|
|
16
17
|
import trio
|
|
@@ -21,12 +22,15 @@ from shrinkray.problem import (
|
|
|
21
22
|
BasicReductionProblem,
|
|
22
23
|
InvalidInitialExample,
|
|
23
24
|
ReductionProblem,
|
|
24
|
-
|
|
25
|
+
sort_key_for_initial,
|
|
25
26
|
)
|
|
26
27
|
from shrinkray.reducer import DirectoryShrinkRay, Reducer, ShrinkRay
|
|
27
28
|
from shrinkray.work import Volume, WorkContext
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
33
|
+
|
|
30
34
|
class TimeoutExceededOnInitial(InvalidInitialExample):
|
|
31
35
|
def __init__(self, runtime: float, timeout: float) -> None:
|
|
32
36
|
self.runtime = runtime
|
|
@@ -55,6 +59,125 @@ def compute_dynamic_timeout(runtime: float) -> float:
|
|
|
55
59
|
)
|
|
56
60
|
|
|
57
61
|
|
|
62
|
+
@define
|
|
63
|
+
class TestOutputManager:
|
|
64
|
+
"""Manages temporary files for test output capture.
|
|
65
|
+
|
|
66
|
+
Allocates unique files for each test's stdout/stderr output,
|
|
67
|
+
tracks active and completed tests, and cleans up old files.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
output_dir: str
|
|
71
|
+
max_files: int = 50
|
|
72
|
+
max_age_seconds: float = 60.0
|
|
73
|
+
min_display_seconds: float = 1.0 # Minimum time to show completed output
|
|
74
|
+
grace_period_seconds: float = (
|
|
75
|
+
0.5 # Extra time to wait for new test after min_display
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_sequence: int = 0
|
|
79
|
+
_active_outputs: dict[int, str] = {}
|
|
80
|
+
_completed_outputs: deque[tuple[int, str, float]] = deque()
|
|
81
|
+
|
|
82
|
+
def __attrs_post_init__(self) -> None:
|
|
83
|
+
# Initialize mutable defaults
|
|
84
|
+
self._active_outputs = {}
|
|
85
|
+
self._completed_outputs = deque()
|
|
86
|
+
|
|
87
|
+
def allocate_output_file(self) -> tuple[int, str]:
|
|
88
|
+
"""Allocate a new output file for a test. Returns (test_id, file_path)."""
|
|
89
|
+
test_id = self._sequence
|
|
90
|
+
self._sequence += 1
|
|
91
|
+
file_path = os.path.join(self.output_dir, f"test_{test_id}.log")
|
|
92
|
+
self._active_outputs[test_id] = file_path
|
|
93
|
+
return test_id, file_path
|
|
94
|
+
|
|
95
|
+
def mark_completed(self, test_id: int) -> None:
|
|
96
|
+
"""Mark a test as completed and move to completed queue."""
|
|
97
|
+
if test_id in self._active_outputs:
|
|
98
|
+
file_path = self._active_outputs.pop(test_id)
|
|
99
|
+
self._completed_outputs.append((test_id, file_path, time.time()))
|
|
100
|
+
self._cleanup_old_files()
|
|
101
|
+
|
|
102
|
+
def _cleanup_old_files(self) -> None:
|
|
103
|
+
"""Remove old output files based on count and age limits."""
|
|
104
|
+
now = time.time()
|
|
105
|
+
# Remove files older than max_age_seconds
|
|
106
|
+
while (
|
|
107
|
+
self._completed_outputs
|
|
108
|
+
and now - self._completed_outputs[0][2] > self.max_age_seconds
|
|
109
|
+
):
|
|
110
|
+
_, file_path, _ = self._completed_outputs.popleft()
|
|
111
|
+
self._safe_delete(file_path)
|
|
112
|
+
# Remove excess files beyond max_files
|
|
113
|
+
while len(self._completed_outputs) > self.max_files:
|
|
114
|
+
_, file_path, _ = self._completed_outputs.popleft()
|
|
115
|
+
self._safe_delete(file_path)
|
|
116
|
+
|
|
117
|
+
def _should_show_completed(self) -> tuple[int, str] | None:
|
|
118
|
+
"""Check if we should show a completed test's output.
|
|
119
|
+
|
|
120
|
+
Note: This method is only called when there are no active tests.
|
|
121
|
+
It returns the completed test info if within the display window
|
|
122
|
+
(min_display_seconds + grace_period).
|
|
123
|
+
"""
|
|
124
|
+
if not self._completed_outputs:
|
|
125
|
+
return None
|
|
126
|
+
test_id, file_path, completion_time = self._completed_outputs[-1]
|
|
127
|
+
elapsed = time.time() - completion_time
|
|
128
|
+
|
|
129
|
+
# Show completed test during the full display window
|
|
130
|
+
if elapsed < self.min_display_seconds + self.grace_period_seconds:
|
|
131
|
+
return test_id, file_path
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def get_current_output_path(self) -> str | None:
|
|
136
|
+
"""Get the most relevant output file path.
|
|
137
|
+
|
|
138
|
+
Active tests always take priority. If no active test, shows
|
|
139
|
+
recently completed test output for min_display_seconds, plus
|
|
140
|
+
an additional grace_period if no new test has started.
|
|
141
|
+
"""
|
|
142
|
+
# Active tests always take priority
|
|
143
|
+
if self._active_outputs:
|
|
144
|
+
max_id = max(self._active_outputs.keys())
|
|
145
|
+
return self._active_outputs[max_id]
|
|
146
|
+
# Then check for recently completed test that should stay visible
|
|
147
|
+
recent = self._should_show_completed()
|
|
148
|
+
if recent is not None:
|
|
149
|
+
return recent[1]
|
|
150
|
+
# Fall back to most recent completed (even if past display window)
|
|
151
|
+
if self._completed_outputs:
|
|
152
|
+
return self._completed_outputs[-1][1]
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def get_active_test_id(self) -> int | None:
|
|
156
|
+
"""Get the currently running test ID, if any.
|
|
157
|
+
|
|
158
|
+
Returns the active test ID if one is running, None otherwise.
|
|
159
|
+
"""
|
|
160
|
+
if self._active_outputs:
|
|
161
|
+
return max(self._active_outputs.keys())
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def cleanup_all(self) -> None:
|
|
165
|
+
"""Clean up all output files (called on shutdown)."""
|
|
166
|
+
for file_path in self._active_outputs.values():
|
|
167
|
+
self._safe_delete(file_path)
|
|
168
|
+
for _, file_path, _ in self._completed_outputs:
|
|
169
|
+
self._safe_delete(file_path)
|
|
170
|
+
self._active_outputs.clear()
|
|
171
|
+
self._completed_outputs.clear()
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _safe_delete(path: str) -> None:
|
|
175
|
+
try:
|
|
176
|
+
os.unlink(path)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
58
181
|
@define(slots=False)
|
|
59
182
|
class ShrinkRayState[TestCase](ABC):
|
|
60
183
|
input_type: Any # InputType from __main__
|
|
@@ -88,6 +211,9 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
88
211
|
# Stores the output from the last debug run
|
|
89
212
|
_last_debug_output: str = ""
|
|
90
213
|
|
|
214
|
+
# Optional output manager for capturing test output (TUI mode)
|
|
215
|
+
output_manager: TestOutputManager | None = None
|
|
216
|
+
|
|
91
217
|
def __attrs_post_init__(self):
|
|
92
218
|
self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
|
|
93
219
|
self.setup_formatter()
|
|
@@ -172,8 +298,17 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
172
298
|
|
|
173
299
|
return completed.returncode
|
|
174
300
|
|
|
175
|
-
#
|
|
176
|
-
|
|
301
|
+
# Determine output handling
|
|
302
|
+
test_id: int | None = None
|
|
303
|
+
output_file_handle = None
|
|
304
|
+
|
|
305
|
+
if self.output_manager is not None:
|
|
306
|
+
# Capture output to a file for TUI display
|
|
307
|
+
test_id, output_path = self.output_manager.allocate_output_file()
|
|
308
|
+
output_file_handle = open(output_path, "wb")
|
|
309
|
+
kwargs["stdout"] = output_file_handle.fileno()
|
|
310
|
+
kwargs["stderr"] = subprocess.STDOUT # Combine stderr into stdout
|
|
311
|
+
elif self.volume == Volume.debug:
|
|
177
312
|
# Inherit stderr from parent process to stream output in real-time
|
|
178
313
|
kwargs["stderr"] = None # None means inherit
|
|
179
314
|
kwargs["stdout"] = subprocess.DEVNULL
|
|
@@ -182,59 +317,66 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
182
317
|
kwargs["stdout"] = subprocess.DEVNULL
|
|
183
318
|
kwargs["stderr"] = subprocess.DEVNULL
|
|
184
319
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
|
|
188
|
-
return trio.run_process(command, **kwargs, task_status=task_status)
|
|
320
|
+
try:
|
|
321
|
+
async with trio.open_nursery() as nursery:
|
|
189
322
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
try:
|
|
194
|
-
# Determine effective timeout for this call
|
|
195
|
-
if self.first_call:
|
|
196
|
-
# For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
|
|
197
|
-
if self.timeout is None:
|
|
198
|
-
effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
|
|
199
|
-
else:
|
|
200
|
-
effective_timeout = self.timeout * 10
|
|
201
|
-
else:
|
|
202
|
-
# For subsequent calls, timeout must be set (either explicit or computed)
|
|
203
|
-
assert self.timeout is not None
|
|
204
|
-
effective_timeout = self.timeout
|
|
205
|
-
|
|
206
|
-
with trio.move_on_after(effective_timeout):
|
|
207
|
-
await sp.wait()
|
|
208
|
-
|
|
209
|
-
runtime = time.time() - start_time
|
|
210
|
-
|
|
211
|
-
if sp.returncode is None:
|
|
212
|
-
# Process didn't terminate before timeout - kill it
|
|
213
|
-
await self._interrupt_wait_and_kill(sp)
|
|
214
|
-
|
|
215
|
-
# Check for timeout violation (only when timeout is explicitly set)
|
|
216
|
-
if (
|
|
217
|
-
self.timeout is not None
|
|
218
|
-
and runtime >= self.timeout
|
|
219
|
-
and self.first_call
|
|
220
|
-
):
|
|
221
|
-
raise TimeoutExceededOnInitial(
|
|
222
|
-
timeout=self.timeout,
|
|
223
|
-
runtime=runtime,
|
|
224
|
-
)
|
|
225
|
-
finally:
|
|
226
|
-
if self.first_call:
|
|
227
|
-
self.initial_exit_code = sp.returncode
|
|
228
|
-
# Set dynamic timeout if not explicitly specified
|
|
229
|
-
if self.timeout is None:
|
|
230
|
-
runtime = time.time() - start_time
|
|
231
|
-
self.timeout = compute_dynamic_timeout(runtime)
|
|
232
|
-
self.first_call = False
|
|
323
|
+
def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
|
|
324
|
+
return trio.run_process(command, **kwargs, task_status=task_status)
|
|
233
325
|
|
|
234
|
-
|
|
235
|
-
|
|
326
|
+
start_time = time.time()
|
|
327
|
+
sp = await nursery.start(call_with_kwargs)
|
|
236
328
|
|
|
237
|
-
|
|
329
|
+
try:
|
|
330
|
+
# Determine effective timeout for this call
|
|
331
|
+
if self.first_call:
|
|
332
|
+
# For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
|
|
333
|
+
if self.timeout is None:
|
|
334
|
+
effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
|
|
335
|
+
else:
|
|
336
|
+
effective_timeout = self.timeout * 10
|
|
337
|
+
else:
|
|
338
|
+
# For subsequent calls, timeout must be set (either explicit or computed)
|
|
339
|
+
assert self.timeout is not None
|
|
340
|
+
effective_timeout = self.timeout
|
|
341
|
+
|
|
342
|
+
with trio.move_on_after(effective_timeout):
|
|
343
|
+
await sp.wait()
|
|
344
|
+
|
|
345
|
+
runtime = time.time() - start_time
|
|
346
|
+
|
|
347
|
+
if sp.returncode is None:
|
|
348
|
+
# Process didn't terminate before timeout - kill it
|
|
349
|
+
await self._interrupt_wait_and_kill(sp)
|
|
350
|
+
|
|
351
|
+
# Check for timeout violation (only when timeout is explicitly set)
|
|
352
|
+
if (
|
|
353
|
+
self.timeout is not None
|
|
354
|
+
and runtime >= self.timeout
|
|
355
|
+
and self.first_call
|
|
356
|
+
):
|
|
357
|
+
raise TimeoutExceededOnInitial(
|
|
358
|
+
timeout=self.timeout,
|
|
359
|
+
runtime=runtime,
|
|
360
|
+
)
|
|
361
|
+
finally:
|
|
362
|
+
if self.first_call:
|
|
363
|
+
self.initial_exit_code = sp.returncode
|
|
364
|
+
# Set dynamic timeout if not explicitly specified
|
|
365
|
+
if self.timeout is None:
|
|
366
|
+
runtime = time.time() - start_time
|
|
367
|
+
self.timeout = compute_dynamic_timeout(runtime)
|
|
368
|
+
self.first_call = False
|
|
369
|
+
|
|
370
|
+
result: int | None = sp.returncode
|
|
371
|
+
assert result is not None
|
|
372
|
+
|
|
373
|
+
return result
|
|
374
|
+
finally:
|
|
375
|
+
# Clean up output file handle and mark test as completed
|
|
376
|
+
if output_file_handle is not None:
|
|
377
|
+
output_file_handle.close()
|
|
378
|
+
if test_id is not None and self.output_manager is not None:
|
|
379
|
+
self.output_manager.mark_completed(test_id)
|
|
238
380
|
|
|
239
381
|
async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
|
|
240
382
|
# Lazy import
|
|
@@ -310,6 +452,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
310
452
|
is_interesting=self.is_interesting,
|
|
311
453
|
initial=self.initial,
|
|
312
454
|
work=work,
|
|
455
|
+
sort_key=sort_key_for_initial(self.initial),
|
|
313
456
|
**self.extra_problem_kwargs,
|
|
314
457
|
)
|
|
315
458
|
|
|
@@ -593,20 +736,9 @@ class ShrinkRayDirectoryState(ShrinkRayState[dict[str, bytes]]):
|
|
|
593
736
|
def setup_formatter(self): ...
|
|
594
737
|
|
|
595
738
|
@property
|
|
596
|
-
def extra_problem_kwargs(self):
|
|
597
|
-
def dict_size(test_case: dict[str, bytes]) -> int:
|
|
598
|
-
return sum(len(v) for v in test_case.values())
|
|
599
|
-
|
|
600
|
-
def dict_sort_key(test_case: dict[str, bytes]) -> Any:
|
|
601
|
-
return (
|
|
602
|
-
len(test_case),
|
|
603
|
-
dict_size(test_case),
|
|
604
|
-
sorted((k, shortlex(v)) for k, v in test_case.items()),
|
|
605
|
-
)
|
|
606
|
-
|
|
739
|
+
def extra_problem_kwargs(self) -> dict[str, Any]:
|
|
607
740
|
return {
|
|
608
|
-
"
|
|
609
|
-
"size": dict_size,
|
|
741
|
+
"size": lambda tc: sum(len(v) for v in tc.values()),
|
|
610
742
|
}
|
|
611
743
|
|
|
612
744
|
def new_reducer(
|
shrinkray/subprocess/client.py
CHANGED
|
@@ -136,6 +136,7 @@ class SubprocessClient:
|
|
|
136
136
|
no_clang_delta: bool = False,
|
|
137
137
|
clang_delta: str = "",
|
|
138
138
|
trivial_is_error: bool = True,
|
|
139
|
+
skip_validation: bool = False,
|
|
139
140
|
) -> Response:
|
|
140
141
|
"""Start the reduction process."""
|
|
141
142
|
params: dict[str, Any] = {
|
|
@@ -149,6 +150,7 @@ class SubprocessClient:
|
|
|
149
150
|
"no_clang_delta": no_clang_delta,
|
|
150
151
|
"clang_delta": clang_delta,
|
|
151
152
|
"trivial_is_error": trivial_is_error,
|
|
153
|
+
"skip_validation": skip_validation,
|
|
152
154
|
}
|
|
153
155
|
if parallelism is not None:
|
|
154
156
|
params["parallelism"] = parallelism
|
shrinkray/subprocess/protocol.py
CHANGED
|
@@ -56,6 +56,10 @@ class ProgressUpdate:
|
|
|
56
56
|
current_pass_name: str = ""
|
|
57
57
|
# List of disabled pass names
|
|
58
58
|
disabled_passes: list[str] = field(default_factory=list)
|
|
59
|
+
# Test output preview (last 4KB of current/recent test output)
|
|
60
|
+
test_output_preview: str = ""
|
|
61
|
+
# Currently running test ID (None if no test running)
|
|
62
|
+
active_test_id: int | None = None
|
|
59
63
|
|
|
60
64
|
|
|
61
65
|
@dataclass
|
|
@@ -115,6 +119,8 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
|
|
|
115
119
|
],
|
|
116
120
|
"current_pass_name": msg.current_pass_name,
|
|
117
121
|
"disabled_passes": msg.disabled_passes,
|
|
122
|
+
"test_output_preview": msg.test_output_preview,
|
|
123
|
+
"active_test_id": msg.active_test_id,
|
|
118
124
|
},
|
|
119
125
|
}
|
|
120
126
|
else:
|
|
@@ -162,6 +168,8 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
|
|
|
162
168
|
pass_stats=pass_stats_data,
|
|
163
169
|
current_pass_name=d.get("current_pass_name", ""),
|
|
164
170
|
disabled_passes=d.get("disabled_passes", []),
|
|
171
|
+
test_output_preview=d.get("test_output_preview", ""),
|
|
172
|
+
active_test_id=d.get("active_test_id"),
|
|
165
173
|
)
|
|
166
174
|
|
|
167
175
|
# Check for response (has "result" or "error" field)
|
shrinkray/subprocess/worker.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Worker subprocess that runs the reducer with trio and communicates via JSON protocol."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import shutil
|
|
4
5
|
import sys
|
|
6
|
+
import tempfile
|
|
5
7
|
import time
|
|
6
8
|
import traceback
|
|
7
9
|
from contextlib import aclosing
|
|
@@ -62,6 +64,8 @@ class ReducerWorker:
|
|
|
62
64
|
# I/O streams - None means use stdin/stdout
|
|
63
65
|
self._input_stream = input_stream
|
|
64
66
|
self._output_stream = output_stream
|
|
67
|
+
# Output directory for test output capture (cleaned up on shutdown)
|
|
68
|
+
self._output_dir: str | None = None
|
|
65
69
|
|
|
66
70
|
async def emit(self, msg: Response | ProgressUpdate) -> None:
|
|
67
71
|
"""Write a message to the output stream."""
|
|
@@ -163,6 +167,7 @@ class ReducerWorker:
|
|
|
163
167
|
from shrinkray.state import (
|
|
164
168
|
ShrinkRayDirectoryState,
|
|
165
169
|
ShrinkRayStateSingleFile,
|
|
170
|
+
TestOutputManager,
|
|
166
171
|
)
|
|
167
172
|
from shrinkray.work import Volume
|
|
168
173
|
|
|
@@ -178,6 +183,7 @@ class ReducerWorker:
|
|
|
178
183
|
no_clang_delta = params.get("no_clang_delta", False)
|
|
179
184
|
clang_delta_path = params.get("clang_delta", "")
|
|
180
185
|
trivial_is_error = params.get("trivial_is_error", True)
|
|
186
|
+
skip_validation = params.get("skip_validation", False)
|
|
181
187
|
|
|
182
188
|
clang_delta_executable = None
|
|
183
189
|
if os.path.splitext(filename)[1] in C_FILE_EXTENSIONS and not no_clang_delta:
|
|
@@ -213,12 +219,18 @@ class ReducerWorker:
|
|
|
213
219
|
initial = reader.read()
|
|
214
220
|
self.state = ShrinkRayStateSingleFile(initial=initial, **state_kwargs)
|
|
215
221
|
|
|
222
|
+
# Create output manager for test output capture (always enabled for TUI)
|
|
223
|
+
self._output_dir = tempfile.mkdtemp(prefix="shrinkray-output-")
|
|
224
|
+
self.state.output_manager = TestOutputManager(output_dir=self._output_dir)
|
|
225
|
+
|
|
216
226
|
self.problem = self.state.problem
|
|
217
227
|
self.reducer = self.state.reducer
|
|
218
228
|
|
|
219
229
|
# Validate initial example before starting - this will raise
|
|
220
|
-
# InvalidInitialExample if the initial test case fails
|
|
221
|
-
|
|
230
|
+
# InvalidInitialExample if the initial test case fails.
|
|
231
|
+
# Skip if validation was already done by the caller (e.g., main()).
|
|
232
|
+
if not skip_validation:
|
|
233
|
+
await self.problem.setup()
|
|
222
234
|
|
|
223
235
|
self.running = True
|
|
224
236
|
|
|
@@ -300,6 +312,32 @@ class ReducerWorker:
|
|
|
300
312
|
return Response(id=request_id, result={"status": "skipped"})
|
|
301
313
|
return Response(id=request_id, error="Reducer does not support pass control")
|
|
302
314
|
|
|
315
|
+
def _get_test_output_preview(self) -> tuple[str, int | None]:
|
|
316
|
+
"""Get preview of current test output and active test ID."""
|
|
317
|
+
if self.state is None or self.state.output_manager is None:
|
|
318
|
+
return "", None
|
|
319
|
+
|
|
320
|
+
manager = self.state.output_manager
|
|
321
|
+
active_test_id = manager.get_active_test_id()
|
|
322
|
+
output_path = manager.get_current_output_path()
|
|
323
|
+
|
|
324
|
+
if output_path is None:
|
|
325
|
+
return "", active_test_id
|
|
326
|
+
|
|
327
|
+
# Read last 4KB of file
|
|
328
|
+
try:
|
|
329
|
+
with open(output_path, "rb") as f:
|
|
330
|
+
f.seek(0, 2) # Seek to end
|
|
331
|
+
size = f.tell()
|
|
332
|
+
if size > 4096:
|
|
333
|
+
f.seek(-4096, 2)
|
|
334
|
+
else:
|
|
335
|
+
f.seek(0)
|
|
336
|
+
data = f.read()
|
|
337
|
+
return data.decode("utf-8", errors="replace"), active_test_id
|
|
338
|
+
except OSError:
|
|
339
|
+
return "", active_test_id
|
|
340
|
+
|
|
303
341
|
def _get_content_preview(self) -> tuple[str, bool]:
|
|
304
342
|
"""Get a preview of the current test case content."""
|
|
305
343
|
if self.problem is None:
|
|
@@ -402,6 +440,9 @@ class ReducerWorker:
|
|
|
402
440
|
else:
|
|
403
441
|
disabled_passes = []
|
|
404
442
|
|
|
443
|
+
# Get test output preview
|
|
444
|
+
test_output_preview, active_test_id = self._get_test_output_preview()
|
|
445
|
+
|
|
405
446
|
return ProgressUpdate(
|
|
406
447
|
status=self.reducer.status if self.reducer else "",
|
|
407
448
|
size=stats.current_test_case_size,
|
|
@@ -420,6 +461,8 @@ class ReducerWorker:
|
|
|
420
461
|
pass_stats=pass_stats_list,
|
|
421
462
|
current_pass_name=current_pass_name,
|
|
422
463
|
disabled_passes=disabled_passes,
|
|
464
|
+
test_output_preview=test_output_preview,
|
|
465
|
+
active_test_id=active_test_id,
|
|
423
466
|
)
|
|
424
467
|
|
|
425
468
|
async def emit_progress_updates(self) -> None:
|
|
@@ -469,25 +512,32 @@ class ReducerWorker:
|
|
|
469
512
|
|
|
470
513
|
async def run(self) -> None:
|
|
471
514
|
"""Main entry point for the worker."""
|
|
472
|
-
|
|
473
|
-
|
|
515
|
+
try:
|
|
516
|
+
async with trio.open_nursery() as nursery:
|
|
517
|
+
await nursery.start(self.read_commands)
|
|
474
518
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
519
|
+
# Wait for start command
|
|
520
|
+
while not self.running:
|
|
521
|
+
await trio.sleep(0.01)
|
|
478
522
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
523
|
+
# Start progress updates and reducer
|
|
524
|
+
nursery.start_soon(self.emit_progress_updates)
|
|
525
|
+
await self.run_reducer()
|
|
482
526
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
527
|
+
# Emit final progress update before completion
|
|
528
|
+
final_update = await self._build_progress_update()
|
|
529
|
+
if final_update is not None:
|
|
530
|
+
await self.emit(final_update)
|
|
487
531
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
532
|
+
# Signal completion
|
|
533
|
+
await self.emit(Response(id="", result={"status": "completed"}))
|
|
534
|
+
nursery.cancel_scope.cancel()
|
|
535
|
+
finally:
|
|
536
|
+
# Clean up test output files and temp directory
|
|
537
|
+
if self.state is not None and self.state.output_manager is not None:
|
|
538
|
+
self.state.output_manager.cleanup_all()
|
|
539
|
+
if self._output_dir is not None and os.path.isdir(self._output_dir):
|
|
540
|
+
shutil.rmtree(self._output_dir, ignore_errors=True)
|
|
491
541
|
|
|
492
542
|
|
|
493
543
|
def main() -> None:
|