shrinkray 25.12.27.2__py3-none-any.whl → 25.12.28.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 +25 -11
- shrinkray/passes/bytes.py +0 -125
- shrinkray/passes/clangdelta.py +0 -24
- shrinkray/passes/genericlanguages.py +0 -20
- shrinkray/passes/json.py +0 -8
- shrinkray/passes/patching.py +0 -63
- shrinkray/passes/sequences.py +0 -25
- shrinkray/reducer.py +0 -50
- shrinkray/state.py +192 -56
- shrinkray/subprocess/__init__.py +0 -4
- shrinkray/subprocess/client.py +2 -0
- shrinkray/subprocess/protocol.py +8 -11
- shrinkray/subprocess/worker.py +67 -25
- shrinkray/tui.py +114 -92
- shrinkray/validation.py +403 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.dist-info}/METADATA +1 -1
- shrinkray-25.12.28.0.dist-info/RECORD +33 -0
- shrinkray/display.py +0 -75
- shrinkray-25.12.27.2.dist-info/RECORD +0 -33
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.dist-info}/WHEEL +0 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.27.2.dist-info → shrinkray-25.12.28.0.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
|
|
14
15
|
|
|
15
16
|
import humanize
|
|
16
17
|
import trio
|
|
@@ -27,9 +28,6 @@ from shrinkray.reducer import DirectoryShrinkRay, Reducer, ShrinkRay
|
|
|
27
28
|
from shrinkray.work import Volume, WorkContext
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
T = TypeVar("T")
|
|
31
|
-
|
|
32
|
-
|
|
33
31
|
class TimeoutExceededOnInitial(InvalidInitialExample):
|
|
34
32
|
def __init__(self, runtime: float, timeout: float) -> None:
|
|
35
33
|
self.runtime = runtime
|
|
@@ -58,6 +56,125 @@ def compute_dynamic_timeout(runtime: float) -> float:
|
|
|
58
56
|
)
|
|
59
57
|
|
|
60
58
|
|
|
59
|
+
@define
|
|
60
|
+
class TestOutputManager:
|
|
61
|
+
"""Manages temporary files for test output capture.
|
|
62
|
+
|
|
63
|
+
Allocates unique files for each test's stdout/stderr output,
|
|
64
|
+
tracks active and completed tests, and cleans up old files.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
output_dir: str
|
|
68
|
+
max_files: int = 50
|
|
69
|
+
max_age_seconds: float = 60.0
|
|
70
|
+
min_display_seconds: float = 1.0 # Minimum time to show completed output
|
|
71
|
+
grace_period_seconds: float = (
|
|
72
|
+
0.5 # Extra time to wait for new test after min_display
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_sequence: int = 0
|
|
76
|
+
_active_outputs: dict[int, str] = {}
|
|
77
|
+
_completed_outputs: deque[tuple[int, str, float]] = deque()
|
|
78
|
+
|
|
79
|
+
def __attrs_post_init__(self) -> None:
|
|
80
|
+
# Initialize mutable defaults
|
|
81
|
+
self._active_outputs = {}
|
|
82
|
+
self._completed_outputs = deque()
|
|
83
|
+
|
|
84
|
+
def allocate_output_file(self) -> tuple[int, str]:
|
|
85
|
+
"""Allocate a new output file for a test. Returns (test_id, file_path)."""
|
|
86
|
+
test_id = self._sequence
|
|
87
|
+
self._sequence += 1
|
|
88
|
+
file_path = os.path.join(self.output_dir, f"test_{test_id}.log")
|
|
89
|
+
self._active_outputs[test_id] = file_path
|
|
90
|
+
return test_id, file_path
|
|
91
|
+
|
|
92
|
+
def mark_completed(self, test_id: int) -> None:
|
|
93
|
+
"""Mark a test as completed and move to completed queue."""
|
|
94
|
+
if test_id in self._active_outputs:
|
|
95
|
+
file_path = self._active_outputs.pop(test_id)
|
|
96
|
+
self._completed_outputs.append((test_id, file_path, time.time()))
|
|
97
|
+
self._cleanup_old_files()
|
|
98
|
+
|
|
99
|
+
def _cleanup_old_files(self) -> None:
|
|
100
|
+
"""Remove old output files based on count and age limits."""
|
|
101
|
+
now = time.time()
|
|
102
|
+
# Remove files older than max_age_seconds
|
|
103
|
+
while (
|
|
104
|
+
self._completed_outputs
|
|
105
|
+
and now - self._completed_outputs[0][2] > self.max_age_seconds
|
|
106
|
+
):
|
|
107
|
+
_, file_path, _ = self._completed_outputs.popleft()
|
|
108
|
+
self._safe_delete(file_path)
|
|
109
|
+
# Remove excess files beyond max_files
|
|
110
|
+
while len(self._completed_outputs) > self.max_files:
|
|
111
|
+
_, file_path, _ = self._completed_outputs.popleft()
|
|
112
|
+
self._safe_delete(file_path)
|
|
113
|
+
|
|
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
|
|
129
|
+
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def get_current_output_path(self) -> str | None:
|
|
133
|
+
"""Get the most relevant output file path.
|
|
134
|
+
|
|
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.
|
|
138
|
+
"""
|
|
139
|
+
# Active tests always take priority
|
|
140
|
+
if self._active_outputs:
|
|
141
|
+
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
|
+
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.
|
|
154
|
+
|
|
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
|
+
|
|
161
|
+
def cleanup_all(self) -> None:
|
|
162
|
+
"""Clean up all output files (called on shutdown)."""
|
|
163
|
+
for file_path in self._active_outputs.values():
|
|
164
|
+
self._safe_delete(file_path)
|
|
165
|
+
for _, file_path, _ in self._completed_outputs:
|
|
166
|
+
self._safe_delete(file_path)
|
|
167
|
+
self._active_outputs.clear()
|
|
168
|
+
self._completed_outputs.clear()
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _safe_delete(path: str) -> None:
|
|
172
|
+
try:
|
|
173
|
+
os.unlink(path)
|
|
174
|
+
except OSError:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
61
178
|
@define(slots=False)
|
|
62
179
|
class ShrinkRayState[TestCase](ABC):
|
|
63
180
|
input_type: Any # InputType from __main__
|
|
@@ -91,6 +208,9 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
91
208
|
# Stores the output from the last debug run
|
|
92
209
|
_last_debug_output: str = ""
|
|
93
210
|
|
|
211
|
+
# Optional output manager for capturing test output (TUI mode)
|
|
212
|
+
output_manager: TestOutputManager | None = None
|
|
213
|
+
|
|
94
214
|
def __attrs_post_init__(self):
|
|
95
215
|
self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
|
|
96
216
|
self.setup_formatter()
|
|
@@ -175,8 +295,17 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
175
295
|
|
|
176
296
|
return completed.returncode
|
|
177
297
|
|
|
178
|
-
#
|
|
179
|
-
|
|
298
|
+
# Determine output handling
|
|
299
|
+
test_id: int | None = None
|
|
300
|
+
output_file_handle = None
|
|
301
|
+
|
|
302
|
+
if self.output_manager is not None:
|
|
303
|
+
# Capture output to a file for TUI display
|
|
304
|
+
test_id, output_path = self.output_manager.allocate_output_file()
|
|
305
|
+
output_file_handle = open(output_path, "wb")
|
|
306
|
+
kwargs["stdout"] = output_file_handle.fileno()
|
|
307
|
+
kwargs["stderr"] = subprocess.STDOUT # Combine stderr into stdout
|
|
308
|
+
elif self.volume == Volume.debug:
|
|
180
309
|
# Inherit stderr from parent process to stream output in real-time
|
|
181
310
|
kwargs["stderr"] = None # None means inherit
|
|
182
311
|
kwargs["stdout"] = subprocess.DEVNULL
|
|
@@ -185,59 +314,66 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
185
314
|
kwargs["stdout"] = subprocess.DEVNULL
|
|
186
315
|
kwargs["stderr"] = subprocess.DEVNULL
|
|
187
316
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
|
|
191
|
-
return trio.run_process(command, **kwargs, task_status=task_status)
|
|
317
|
+
try:
|
|
318
|
+
async with trio.open_nursery() as nursery:
|
|
192
319
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
try:
|
|
197
|
-
# Determine effective timeout for this call
|
|
198
|
-
if self.first_call:
|
|
199
|
-
# For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
|
|
200
|
-
if self.timeout is None:
|
|
201
|
-
effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
|
|
202
|
-
else:
|
|
203
|
-
effective_timeout = self.timeout * 10
|
|
204
|
-
else:
|
|
205
|
-
# For subsequent calls, timeout must be set (either explicit or computed)
|
|
206
|
-
assert self.timeout is not None
|
|
207
|
-
effective_timeout = self.timeout
|
|
208
|
-
|
|
209
|
-
with trio.move_on_after(effective_timeout):
|
|
210
|
-
await sp.wait()
|
|
211
|
-
|
|
212
|
-
runtime = time.time() - start_time
|
|
213
|
-
|
|
214
|
-
if sp.returncode is None:
|
|
215
|
-
# Process didn't terminate before timeout - kill it
|
|
216
|
-
await self._interrupt_wait_and_kill(sp)
|
|
217
|
-
|
|
218
|
-
# Check for timeout violation (only when timeout is explicitly set)
|
|
219
|
-
if (
|
|
220
|
-
self.timeout is not None
|
|
221
|
-
and runtime >= self.timeout
|
|
222
|
-
and self.first_call
|
|
223
|
-
):
|
|
224
|
-
raise TimeoutExceededOnInitial(
|
|
225
|
-
timeout=self.timeout,
|
|
226
|
-
runtime=runtime,
|
|
227
|
-
)
|
|
228
|
-
finally:
|
|
229
|
-
if self.first_call:
|
|
230
|
-
self.initial_exit_code = sp.returncode
|
|
231
|
-
# Set dynamic timeout if not explicitly specified
|
|
232
|
-
if self.timeout is None:
|
|
233
|
-
runtime = time.time() - start_time
|
|
234
|
-
self.timeout = compute_dynamic_timeout(runtime)
|
|
235
|
-
self.first_call = False
|
|
320
|
+
def call_with_kwargs(task_status=trio.TASK_STATUS_IGNORED): # type: ignore
|
|
321
|
+
return trio.run_process(command, **kwargs, task_status=task_status)
|
|
236
322
|
|
|
237
|
-
|
|
238
|
-
|
|
323
|
+
start_time = time.time()
|
|
324
|
+
sp = await nursery.start(call_with_kwargs)
|
|
239
325
|
|
|
240
|
-
|
|
326
|
+
try:
|
|
327
|
+
# Determine effective timeout for this call
|
|
328
|
+
if self.first_call:
|
|
329
|
+
# For first call: use calibration timeout if dynamic, otherwise 10x explicit timeout
|
|
330
|
+
if self.timeout is None:
|
|
331
|
+
effective_timeout = DYNAMIC_TIMEOUT_CALIBRATION_TIMEOUT
|
|
332
|
+
else:
|
|
333
|
+
effective_timeout = self.timeout * 10
|
|
334
|
+
else:
|
|
335
|
+
# For subsequent calls, timeout must be set (either explicit or computed)
|
|
336
|
+
assert self.timeout is not None
|
|
337
|
+
effective_timeout = self.timeout
|
|
338
|
+
|
|
339
|
+
with trio.move_on_after(effective_timeout):
|
|
340
|
+
await sp.wait()
|
|
341
|
+
|
|
342
|
+
runtime = time.time() - start_time
|
|
343
|
+
|
|
344
|
+
if sp.returncode is None:
|
|
345
|
+
# Process didn't terminate before timeout - kill it
|
|
346
|
+
await self._interrupt_wait_and_kill(sp)
|
|
347
|
+
|
|
348
|
+
# Check for timeout violation (only when timeout is explicitly set)
|
|
349
|
+
if (
|
|
350
|
+
self.timeout is not None
|
|
351
|
+
and runtime >= self.timeout
|
|
352
|
+
and self.first_call
|
|
353
|
+
):
|
|
354
|
+
raise TimeoutExceededOnInitial(
|
|
355
|
+
timeout=self.timeout,
|
|
356
|
+
runtime=runtime,
|
|
357
|
+
)
|
|
358
|
+
finally:
|
|
359
|
+
if self.first_call:
|
|
360
|
+
self.initial_exit_code = sp.returncode
|
|
361
|
+
# Set dynamic timeout if not explicitly specified
|
|
362
|
+
if self.timeout is None:
|
|
363
|
+
runtime = time.time() - start_time
|
|
364
|
+
self.timeout = compute_dynamic_timeout(runtime)
|
|
365
|
+
self.first_call = False
|
|
366
|
+
|
|
367
|
+
result: int | None = sp.returncode
|
|
368
|
+
assert result is not None
|
|
369
|
+
|
|
370
|
+
return result
|
|
371
|
+
finally:
|
|
372
|
+
# Clean up output file handle and mark test as completed
|
|
373
|
+
if output_file_handle is not None:
|
|
374
|
+
output_file_handle.close()
|
|
375
|
+
if test_id is not None and self.output_manager is not None:
|
|
376
|
+
self.output_manager.mark_completed(test_id)
|
|
241
377
|
|
|
242
378
|
async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
|
|
243
379
|
# Lazy import
|
shrinkray/subprocess/__init__.py
CHANGED
|
@@ -5,9 +5,7 @@ from shrinkray.subprocess.protocol import (
|
|
|
5
5
|
ProgressUpdate,
|
|
6
6
|
Request,
|
|
7
7
|
Response,
|
|
8
|
-
decode_bytes,
|
|
9
8
|
deserialize,
|
|
10
|
-
encode_bytes,
|
|
11
9
|
serialize,
|
|
12
10
|
)
|
|
13
11
|
|
|
@@ -18,7 +16,5 @@ __all__ = [
|
|
|
18
16
|
"ProgressUpdate",
|
|
19
17
|
"serialize",
|
|
20
18
|
"deserialize",
|
|
21
|
-
"encode_bytes",
|
|
22
|
-
"decode_bytes",
|
|
23
19
|
"SubprocessClient",
|
|
24
20
|
]
|
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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""Line-oriented JSON protocol for subprocess communication."""
|
|
2
2
|
|
|
3
|
-
import base64
|
|
4
3
|
import json
|
|
5
4
|
from dataclasses import dataclass, field
|
|
6
5
|
from typing import Any
|
|
@@ -56,6 +55,10 @@ class ProgressUpdate:
|
|
|
56
55
|
current_pass_name: str = ""
|
|
57
56
|
# List of disabled pass names
|
|
58
57
|
disabled_passes: list[str] = field(default_factory=list)
|
|
58
|
+
# Test output preview (last 4KB of current/recent test output)
|
|
59
|
+
test_output_preview: str = ""
|
|
60
|
+
# Currently running test ID (None if no test running)
|
|
61
|
+
active_test_id: int | None = None
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
@dataclass
|
|
@@ -115,6 +118,8 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
|
|
|
115
118
|
],
|
|
116
119
|
"current_pass_name": msg.current_pass_name,
|
|
117
120
|
"disabled_passes": msg.disabled_passes,
|
|
121
|
+
"test_output_preview": msg.test_output_preview,
|
|
122
|
+
"active_test_id": msg.active_test_id,
|
|
118
123
|
},
|
|
119
124
|
}
|
|
120
125
|
else:
|
|
@@ -162,6 +167,8 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
|
|
|
162
167
|
pass_stats=pass_stats_data,
|
|
163
168
|
current_pass_name=d.get("current_pass_name", ""),
|
|
164
169
|
disabled_passes=d.get("disabled_passes", []),
|
|
170
|
+
test_output_preview=d.get("test_output_preview", ""),
|
|
171
|
+
active_test_id=d.get("active_test_id"),
|
|
165
172
|
)
|
|
166
173
|
|
|
167
174
|
# Check for response (has "result" or "error" field)
|
|
@@ -178,13 +185,3 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
|
|
|
178
185
|
command=data["command"],
|
|
179
186
|
params=data.get("params", {}),
|
|
180
187
|
)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def encode_bytes(data: bytes) -> str:
|
|
184
|
-
"""Encode bytes to base64 string for JSON transport."""
|
|
185
|
-
return base64.b64encode(data).decode("ascii")
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def decode_bytes(data: str) -> bytes:
|
|
189
|
-
"""Decode base64 string back to bytes."""
|
|
190
|
-
return base64.b64decode(data.encode("ascii"))
|
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
|
|
@@ -35,14 +37,6 @@ class OutputStream(Protocol):
|
|
|
35
37
|
async def send(self, data: bytes) -> None: ...
|
|
36
38
|
|
|
37
39
|
|
|
38
|
-
class StdoutStream:
|
|
39
|
-
"""Wrapper around sys.stdout for the OutputStream protocol."""
|
|
40
|
-
|
|
41
|
-
async def send(self, data: bytes) -> None:
|
|
42
|
-
sys.stdout.write(data.decode("utf-8"))
|
|
43
|
-
sys.stdout.flush()
|
|
44
|
-
|
|
45
|
-
|
|
46
40
|
class ReducerWorker:
|
|
47
41
|
"""Runs the reducer in a subprocess with JSON protocol communication."""
|
|
48
42
|
|
|
@@ -62,6 +56,8 @@ class ReducerWorker:
|
|
|
62
56
|
# I/O streams - None means use stdin/stdout
|
|
63
57
|
self._input_stream = input_stream
|
|
64
58
|
self._output_stream = output_stream
|
|
59
|
+
# Output directory for test output capture (cleaned up on shutdown)
|
|
60
|
+
self._output_dir: str | None = None
|
|
65
61
|
|
|
66
62
|
async def emit(self, msg: Response | ProgressUpdate) -> None:
|
|
67
63
|
"""Write a message to the output stream."""
|
|
@@ -163,6 +159,7 @@ class ReducerWorker:
|
|
|
163
159
|
from shrinkray.state import (
|
|
164
160
|
ShrinkRayDirectoryState,
|
|
165
161
|
ShrinkRayStateSingleFile,
|
|
162
|
+
TestOutputManager,
|
|
166
163
|
)
|
|
167
164
|
from shrinkray.work import Volume
|
|
168
165
|
|
|
@@ -178,6 +175,7 @@ class ReducerWorker:
|
|
|
178
175
|
no_clang_delta = params.get("no_clang_delta", False)
|
|
179
176
|
clang_delta_path = params.get("clang_delta", "")
|
|
180
177
|
trivial_is_error = params.get("trivial_is_error", True)
|
|
178
|
+
skip_validation = params.get("skip_validation", False)
|
|
181
179
|
|
|
182
180
|
clang_delta_executable = None
|
|
183
181
|
if os.path.splitext(filename)[1] in C_FILE_EXTENSIONS and not no_clang_delta:
|
|
@@ -213,12 +211,18 @@ class ReducerWorker:
|
|
|
213
211
|
initial = reader.read()
|
|
214
212
|
self.state = ShrinkRayStateSingleFile(initial=initial, **state_kwargs)
|
|
215
213
|
|
|
214
|
+
# Create output manager for test output capture (always enabled for TUI)
|
|
215
|
+
self._output_dir = tempfile.mkdtemp(prefix="shrinkray-output-")
|
|
216
|
+
self.state.output_manager = TestOutputManager(output_dir=self._output_dir)
|
|
217
|
+
|
|
216
218
|
self.problem = self.state.problem
|
|
217
219
|
self.reducer = self.state.reducer
|
|
218
220
|
|
|
219
221
|
# Validate initial example before starting - this will raise
|
|
220
|
-
# InvalidInitialExample if the initial test case fails
|
|
221
|
-
|
|
222
|
+
# InvalidInitialExample if the initial test case fails.
|
|
223
|
+
# Skip if validation was already done by the caller (e.g., main()).
|
|
224
|
+
if not skip_validation:
|
|
225
|
+
await self.problem.setup()
|
|
222
226
|
|
|
223
227
|
self.running = True
|
|
224
228
|
|
|
@@ -300,6 +304,32 @@ class ReducerWorker:
|
|
|
300
304
|
return Response(id=request_id, result={"status": "skipped"})
|
|
301
305
|
return Response(id=request_id, error="Reducer does not support pass control")
|
|
302
306
|
|
|
307
|
+
def _get_test_output_preview(self) -> tuple[str, int | None]:
|
|
308
|
+
"""Get preview of current test output and active test ID."""
|
|
309
|
+
if self.state is None or self.state.output_manager is None:
|
|
310
|
+
return "", None
|
|
311
|
+
|
|
312
|
+
manager = self.state.output_manager
|
|
313
|
+
active_test_id = manager.get_active_test_id()
|
|
314
|
+
output_path = manager.get_current_output_path()
|
|
315
|
+
|
|
316
|
+
if output_path is None:
|
|
317
|
+
return "", active_test_id
|
|
318
|
+
|
|
319
|
+
# Read last 4KB of file
|
|
320
|
+
try:
|
|
321
|
+
with open(output_path, "rb") as f:
|
|
322
|
+
f.seek(0, 2) # Seek to end
|
|
323
|
+
size = f.tell()
|
|
324
|
+
if size > 4096:
|
|
325
|
+
f.seek(-4096, 2)
|
|
326
|
+
else:
|
|
327
|
+
f.seek(0)
|
|
328
|
+
data = f.read()
|
|
329
|
+
return data.decode("utf-8", errors="replace"), active_test_id
|
|
330
|
+
except OSError:
|
|
331
|
+
return "", active_test_id
|
|
332
|
+
|
|
303
333
|
def _get_content_preview(self) -> tuple[str, bool]:
|
|
304
334
|
"""Get a preview of the current test case content."""
|
|
305
335
|
if self.problem is None:
|
|
@@ -402,6 +432,9 @@ class ReducerWorker:
|
|
|
402
432
|
else:
|
|
403
433
|
disabled_passes = []
|
|
404
434
|
|
|
435
|
+
# Get test output preview
|
|
436
|
+
test_output_preview, active_test_id = self._get_test_output_preview()
|
|
437
|
+
|
|
405
438
|
return ProgressUpdate(
|
|
406
439
|
status=self.reducer.status if self.reducer else "",
|
|
407
440
|
size=stats.current_test_case_size,
|
|
@@ -420,6 +453,8 @@ class ReducerWorker:
|
|
|
420
453
|
pass_stats=pass_stats_list,
|
|
421
454
|
current_pass_name=current_pass_name,
|
|
422
455
|
disabled_passes=disabled_passes,
|
|
456
|
+
test_output_preview=test_output_preview,
|
|
457
|
+
active_test_id=active_test_id,
|
|
423
458
|
)
|
|
424
459
|
|
|
425
460
|
async def emit_progress_updates(self) -> None:
|
|
@@ -469,25 +504,32 @@ class ReducerWorker:
|
|
|
469
504
|
|
|
470
505
|
async def run(self) -> None:
|
|
471
506
|
"""Main entry point for the worker."""
|
|
472
|
-
|
|
473
|
-
|
|
507
|
+
try:
|
|
508
|
+
async with trio.open_nursery() as nursery:
|
|
509
|
+
await nursery.start(self.read_commands)
|
|
474
510
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
511
|
+
# Wait for start command
|
|
512
|
+
while not self.running:
|
|
513
|
+
await trio.sleep(0.01)
|
|
478
514
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
515
|
+
# Start progress updates and reducer
|
|
516
|
+
nursery.start_soon(self.emit_progress_updates)
|
|
517
|
+
await self.run_reducer()
|
|
482
518
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
519
|
+
# Emit final progress update before completion
|
|
520
|
+
final_update = await self._build_progress_update()
|
|
521
|
+
if final_update is not None:
|
|
522
|
+
await self.emit(final_update)
|
|
487
523
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
524
|
+
# Signal completion
|
|
525
|
+
await self.emit(Response(id="", result={"status": "completed"}))
|
|
526
|
+
nursery.cancel_scope.cancel()
|
|
527
|
+
finally:
|
|
528
|
+
# Clean up test output files and temp directory
|
|
529
|
+
if self.state is not None and self.state.output_manager is not None:
|
|
530
|
+
self.state.output_manager.cleanup_all()
|
|
531
|
+
if self._output_dir is not None and os.path.isdir(self._output_dir):
|
|
532
|
+
shutil.rmtree(self._output_dir, ignore_errors=True)
|
|
491
533
|
|
|
492
534
|
|
|
493
535
|
def main() -> None:
|