shrinkray 25.12.28.0__tar.gz → 25.12.29.0__tar.gz
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-25.12.28.0/src/shrinkray.egg-info → shrinkray-25.12.29.0}/PKG-INFO +10 -2
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/README.md +6 -1
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/pyproject.toml +5 -2
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/state.py +42 -47
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/protocol.py +10 -1
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/worker.py +59 -13
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/tui.py +605 -32
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0/src/shrinkray.egg-info}/PKG-INFO +10 -2
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/requires.txt +3 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_state.py +129 -39
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_subprocess_worker.py +75 -11
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_tui.py +3836 -1743
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_validation.py +1 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/LICENSE +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/setup.cfg +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/__init__.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/__main__.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/cli.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/formatting.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/__init__.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/bytes.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/clangdelta.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/definitions.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/genericlanguages.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/json.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/patching.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/python.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/sat.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/sequences.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/problem.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/process.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/py.typed +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/reducer.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/__init__.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/client.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/ui.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/validation.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/work.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/SOURCES.txt +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/dependency_links.txt +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/entry_points.txt +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/top_level.txt +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_byte_reduction_passes.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_clang_delta.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_cli.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_definitions.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_dimacs_cnf.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_formatting.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_generic_language.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_generic_shrinking_properties.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_json_passes.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_main.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_misc_reduction_performance.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_natural_sort_orders.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_patching.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_problem.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_process.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_python_reducers.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_reducer.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_reduction_passes.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_sat.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_subprocess_client.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_subprocess_integration.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_subprocess_protocol.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_tui_snapshots.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_ui.py +0 -0
- {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_work.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shrinkray
|
|
3
|
-
Version: 25.12.
|
|
3
|
+
Version: 25.12.29.0
|
|
4
4
|
Summary: Shrink Ray
|
|
5
5
|
Author-email: "David R. MacIver" <david@drmaciver.com>
|
|
6
6
|
License: MIT
|
|
@@ -16,6 +16,7 @@ Requires-Dist: click>=8.0.1
|
|
|
16
16
|
Requires-Dist: chardet>=5.2.0
|
|
17
17
|
Requires-Dist: trio>=0.28.0
|
|
18
18
|
Requires-Dist: textual>=0.47.0
|
|
19
|
+
Requires-Dist: textual-plotext>=0.2.0
|
|
19
20
|
Requires-Dist: humanize>=4.9.0
|
|
20
21
|
Requires-Dist: libcst>=1.1.0
|
|
21
22
|
Requires-Dist: exceptiongroup>=1.2.0
|
|
@@ -28,6 +29,8 @@ Requires-Dist: hypothesmith>=0.3.1; extra == "dev"
|
|
|
28
29
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
29
30
|
Requires-Dist: pytest-trio>=0.8.0; extra == "dev"
|
|
30
31
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-xdist>=3.5.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
31
34
|
Requires-Dist: syrupy>=5.0.0; extra == "dev"
|
|
32
35
|
Requires-Dist: jinja2>=3.0.0; extra == "dev"
|
|
33
36
|
Requires-Dist: coverage[toml]>=7.4.0; extra == "dev"
|
|
@@ -78,7 +81,12 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
|
|
|
78
81
|
|
|
79
82
|
While it runs, you will see the following user interface:
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
<video controls poster="gallery/enterprise-hello/hello.png">
|
|
85
|
+
<source src="https://drmaciver.github.io/shrinkray/assets/hello.mp4" type="video/mp4">
|
|
86
|
+
Your browser doesn't support video. <a href="gallery/enterprise-hello/hello.gif">View the GIF instead</a>.
|
|
87
|
+
</video>
|
|
88
|
+
|
|
89
|
+
(This is a toy example based on reducing a ridiculously bad version of hello world)
|
|
82
90
|
|
|
83
91
|
When it finishes you will be left with the reduced test case in `mytestcase.py`.
|
|
84
92
|
|
|
@@ -38,7 +38,12 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
|
|
|
38
38
|
|
|
39
39
|
While it runs, you will see the following user interface:
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
<video controls poster="gallery/enterprise-hello/hello.png">
|
|
42
|
+
<source src="https://drmaciver.github.io/shrinkray/assets/hello.mp4" type="video/mp4">
|
|
43
|
+
Your browser doesn't support video. <a href="gallery/enterprise-hello/hello.gif">View the GIF instead</a>.
|
|
44
|
+
</video>
|
|
45
|
+
|
|
46
|
+
(This is a toy example based on reducing a ridiculously bad version of hello world)
|
|
42
47
|
|
|
43
48
|
When it finishes you will be left with the reduced test case in `mytestcase.py`.
|
|
44
49
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "shrinkray"
|
|
3
|
-
version = "25.12.
|
|
3
|
+
version = "25.12.29.0"
|
|
4
4
|
description = "Shrink Ray"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "David R. MacIver", email = "david@drmaciver.com"}
|
|
@@ -16,6 +16,7 @@ dependencies = [
|
|
|
16
16
|
"chardet>=5.2.0",
|
|
17
17
|
"trio>=0.28.0",
|
|
18
18
|
"textual>=0.47.0",
|
|
19
|
+
"textual-plotext>=0.2.0",
|
|
19
20
|
"humanize>=4.9.0",
|
|
20
21
|
"libcst>=1.1.0",
|
|
21
22
|
"exceptiongroup>=1.2.0",
|
|
@@ -41,6 +42,8 @@ dev = [
|
|
|
41
42
|
"pytest>=8.0.0",
|
|
42
43
|
"pytest-trio>=0.8.0",
|
|
43
44
|
"pytest-asyncio>=0.21.0",
|
|
45
|
+
"pytest-xdist>=3.5.0",
|
|
46
|
+
"pytest-cov>=4.1.0",
|
|
44
47
|
# pytest-textual-snapshot is vendored in tests/pytest_textual_snapshot.py
|
|
45
48
|
# to fix syrupy 5.0 compatibility. These are its dependencies:
|
|
46
49
|
"syrupy>=5.0.0",
|
|
@@ -67,7 +70,7 @@ core = "ctrace"
|
|
|
67
70
|
|
|
68
71
|
[tool.coverage.report]
|
|
69
72
|
show_missing = true
|
|
70
|
-
fail_under
|
|
73
|
+
# fail_under is enforced in justfile to allow split parallel/serial runs
|
|
71
74
|
|
|
72
75
|
[tool.ruff]
|
|
73
76
|
line-length = 88
|
|
@@ -57,7 +57,7 @@ def compute_dynamic_timeout(runtime: float) -> float:
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
@define
|
|
60
|
-
class
|
|
60
|
+
class OutputCaptureManager:
|
|
61
61
|
"""Manages temporary files for test output capture.
|
|
62
62
|
|
|
63
63
|
Allocates unique files for each test's stdout/stderr output,
|
|
@@ -74,7 +74,8 @@ class TestOutputManager:
|
|
|
74
74
|
|
|
75
75
|
_sequence: int = 0
|
|
76
76
|
_active_outputs: dict[int, str] = {}
|
|
77
|
-
|
|
77
|
+
# Completed outputs: (test_id, file_path, completion_time, return_code)
|
|
78
|
+
_completed_outputs: deque[tuple[int, str, float, int]] = deque()
|
|
78
79
|
|
|
79
80
|
def __attrs_post_init__(self) -> None:
|
|
80
81
|
# Initialize mutable defaults
|
|
@@ -89,11 +90,13 @@ class TestOutputManager:
|
|
|
89
90
|
self._active_outputs[test_id] = file_path
|
|
90
91
|
return test_id, file_path
|
|
91
92
|
|
|
92
|
-
def mark_completed(self, test_id: int) -> None:
|
|
93
|
+
def mark_completed(self, test_id: int, return_code: int = 0) -> None:
|
|
93
94
|
"""Mark a test as completed and move to completed queue."""
|
|
94
95
|
if test_id in self._active_outputs:
|
|
95
96
|
file_path = self._active_outputs.pop(test_id)
|
|
96
|
-
self._completed_outputs.append(
|
|
97
|
+
self._completed_outputs.append(
|
|
98
|
+
(test_id, file_path, time.time(), return_code)
|
|
99
|
+
)
|
|
97
100
|
self._cleanup_old_files()
|
|
98
101
|
|
|
99
102
|
def _cleanup_old_files(self) -> None:
|
|
@@ -104,65 +107,55 @@ class TestOutputManager:
|
|
|
104
107
|
self._completed_outputs
|
|
105
108
|
and now - self._completed_outputs[0][2] > self.max_age_seconds
|
|
106
109
|
):
|
|
107
|
-
_, file_path, _ = self._completed_outputs.popleft()
|
|
110
|
+
_, file_path, _, _ = self._completed_outputs.popleft()
|
|
108
111
|
self._safe_delete(file_path)
|
|
109
112
|
# Remove excess files beyond max_files
|
|
110
113
|
while len(self._completed_outputs) > self.max_files:
|
|
111
|
-
_, file_path, _ = self._completed_outputs.popleft()
|
|
114
|
+
_, file_path, _, _ = self._completed_outputs.popleft()
|
|
112
115
|
self._safe_delete(file_path)
|
|
113
116
|
|
|
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
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _file_has_content(path: str) -> bool:
|
|
119
|
+
"""Check if a file exists and has non-zero size."""
|
|
120
|
+
try:
|
|
121
|
+
return os.path.getsize(path) > 0
|
|
122
|
+
except OSError:
|
|
123
|
+
return False
|
|
125
124
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return test_id, file_path
|
|
125
|
+
def get_current_output(self) -> tuple[str | None, int | None, int | None]:
|
|
126
|
+
"""Get the current output to display.
|
|
129
127
|
|
|
130
|
-
|
|
128
|
+
Returns (file_path, test_id, return_code) where:
|
|
129
|
+
- file_path: path to the output file to display
|
|
130
|
+
- test_id: the test ID (for display in header)
|
|
131
|
+
- return_code: the return code (None if test is still running)
|
|
131
132
|
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
133
|
+
Active tests take priority only if they have produced output.
|
|
134
|
+
Otherwise, shows recently completed test output for min_display_seconds,
|
|
135
|
+
plus an additional grace_period if no new test has started.
|
|
138
136
|
"""
|
|
139
|
-
# Active tests
|
|
137
|
+
# Active tests take priority only if they have content
|
|
140
138
|
if self._active_outputs:
|
|
141
139
|
max_id = max(self._active_outputs.keys())
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
active_path = self._active_outputs[max_id]
|
|
141
|
+
if self._file_has_content(active_path):
|
|
142
|
+
# Active test with output - no return code yet
|
|
143
|
+
return active_path, max_id, None
|
|
144
|
+
# Active test has no output yet - fall through to show previous output
|
|
145
|
+
|
|
146
|
+
# Check for recently completed test that should stay visible,
|
|
147
|
+
# or fall back to most recent completed (even if past display window)
|
|
148
148
|
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.
|
|
149
|
+
test_id, file_path, _, return_code = self._completed_outputs[-1]
|
|
150
|
+
return file_path, test_id, return_code
|
|
154
151
|
|
|
155
|
-
|
|
156
|
-
"""
|
|
157
|
-
if self._active_outputs:
|
|
158
|
-
return max(self._active_outputs.keys())
|
|
159
|
-
return None
|
|
152
|
+
return None, None, None
|
|
160
153
|
|
|
161
154
|
def cleanup_all(self) -> None:
|
|
162
155
|
"""Clean up all output files (called on shutdown)."""
|
|
163
156
|
for file_path in self._active_outputs.values():
|
|
164
157
|
self._safe_delete(file_path)
|
|
165
|
-
for _, file_path, _ in self._completed_outputs:
|
|
158
|
+
for _, file_path, _, _ in self._completed_outputs:
|
|
166
159
|
self._safe_delete(file_path)
|
|
167
160
|
self._active_outputs.clear()
|
|
168
161
|
self._completed_outputs.clear()
|
|
@@ -209,7 +202,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
209
202
|
_last_debug_output: str = ""
|
|
210
203
|
|
|
211
204
|
# Optional output manager for capturing test output (TUI mode)
|
|
212
|
-
output_manager:
|
|
205
|
+
output_manager: OutputCaptureManager | None = None
|
|
213
206
|
|
|
214
207
|
def __attrs_post_init__(self):
|
|
215
208
|
self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
|
|
@@ -298,6 +291,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
298
291
|
# Determine output handling
|
|
299
292
|
test_id: int | None = None
|
|
300
293
|
output_file_handle = None
|
|
294
|
+
exit_code: int | None = None # Track for output manager
|
|
301
295
|
|
|
302
296
|
if self.output_manager is not None:
|
|
303
297
|
# Capture output to a file for TUI display
|
|
@@ -366,6 +360,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
366
360
|
|
|
367
361
|
result: int | None = sp.returncode
|
|
368
362
|
assert result is not None
|
|
363
|
+
exit_code = result
|
|
369
364
|
|
|
370
365
|
return result
|
|
371
366
|
finally:
|
|
@@ -373,7 +368,7 @@ class ShrinkRayState[TestCase](ABC):
|
|
|
373
368
|
if output_file_handle is not None:
|
|
374
369
|
output_file_handle.close()
|
|
375
370
|
if test_id is not None and self.output_manager is not None:
|
|
376
|
-
self.output_manager.mark_completed(test_id)
|
|
371
|
+
self.output_manager.mark_completed(test_id, exit_code or 0)
|
|
377
372
|
|
|
378
373
|
async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
|
|
379
374
|
# Lazy import
|
|
@@ -57,8 +57,13 @@ class ProgressUpdate:
|
|
|
57
57
|
disabled_passes: list[str] = field(default_factory=list)
|
|
58
58
|
# Test output preview (last 4KB of current/recent test output)
|
|
59
59
|
test_output_preview: str = ""
|
|
60
|
-
#
|
|
60
|
+
# Test ID of the output being displayed (None if no output yet)
|
|
61
61
|
active_test_id: int | None = None
|
|
62
|
+
# Return code of the displayed test (None if test is still running)
|
|
63
|
+
last_test_return_code: int | None = None
|
|
64
|
+
# New size history entries since last update: list of (runtime_seconds, size)
|
|
65
|
+
# Client should accumulate these over time
|
|
66
|
+
new_size_history: list[tuple[float, int]] = field(default_factory=list)
|
|
62
67
|
|
|
63
68
|
|
|
64
69
|
@dataclass
|
|
@@ -120,6 +125,8 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
|
|
|
120
125
|
"disabled_passes": msg.disabled_passes,
|
|
121
126
|
"test_output_preview": msg.test_output_preview,
|
|
122
127
|
"active_test_id": msg.active_test_id,
|
|
128
|
+
"last_test_return_code": msg.last_test_return_code,
|
|
129
|
+
"new_size_history": msg.new_size_history,
|
|
123
130
|
},
|
|
124
131
|
}
|
|
125
132
|
else:
|
|
@@ -169,6 +176,8 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
|
|
|
169
176
|
disabled_passes=d.get("disabled_passes", []),
|
|
170
177
|
test_output_preview=d.get("test_output_preview", ""),
|
|
171
178
|
active_test_id=d.get("active_test_id"),
|
|
179
|
+
last_test_return_code=d.get("last_test_return_code"),
|
|
180
|
+
new_size_history=[tuple(x) for x in d.get("new_size_history", [])],
|
|
172
181
|
)
|
|
173
182
|
|
|
174
183
|
# Check for response (has "result" or "error" field)
|
|
@@ -58,6 +58,11 @@ class ReducerWorker:
|
|
|
58
58
|
self._output_stream = output_stream
|
|
59
59
|
# Output directory for test output capture (cleaned up on shutdown)
|
|
60
60
|
self._output_dir: str | None = None
|
|
61
|
+
# Size history for graphing: list of (runtime_seconds, size) tuples
|
|
62
|
+
self._size_history: list[tuple[float, int]] = []
|
|
63
|
+
self._last_sent_history_index: int = 0
|
|
64
|
+
self._last_recorded_size: int = 0
|
|
65
|
+
self._last_history_time: float = 0.0
|
|
61
66
|
|
|
62
67
|
async def emit(self, msg: Response | ProgressUpdate) -> None:
|
|
63
68
|
"""Write a message to the output stream."""
|
|
@@ -157,9 +162,9 @@ class ReducerWorker:
|
|
|
157
162
|
find_clang_delta,
|
|
158
163
|
)
|
|
159
164
|
from shrinkray.state import (
|
|
165
|
+
OutputCaptureManager,
|
|
160
166
|
ShrinkRayDirectoryState,
|
|
161
167
|
ShrinkRayStateSingleFile,
|
|
162
|
-
TestOutputManager,
|
|
163
168
|
)
|
|
164
169
|
from shrinkray.work import Volume
|
|
165
170
|
|
|
@@ -213,7 +218,7 @@ class ReducerWorker:
|
|
|
213
218
|
|
|
214
219
|
# Create output manager for test output capture (always enabled for TUI)
|
|
215
220
|
self._output_dir = tempfile.mkdtemp(prefix="shrinkray-output-")
|
|
216
|
-
self.state.output_manager =
|
|
221
|
+
self.state.output_manager = OutputCaptureManager(output_dir=self._output_dir)
|
|
217
222
|
|
|
218
223
|
self.problem = self.state.problem
|
|
219
224
|
self.reducer = self.state.reducer
|
|
@@ -304,17 +309,23 @@ class ReducerWorker:
|
|
|
304
309
|
return Response(id=request_id, result={"status": "skipped"})
|
|
305
310
|
return Response(id=request_id, error="Reducer does not support pass control")
|
|
306
311
|
|
|
307
|
-
def _get_test_output_preview(self) -> tuple[str, int | None]:
|
|
308
|
-
"""Get preview of current test output and
|
|
312
|
+
def _get_test_output_preview(self) -> tuple[str, int | None, int | None]:
|
|
313
|
+
"""Get preview of current test output, test ID, and return code.
|
|
314
|
+
|
|
315
|
+
Returns (content, test_id, return_code) where:
|
|
316
|
+
- content: the last 4KB of the output file
|
|
317
|
+
- test_id: the test ID being displayed
|
|
318
|
+
- return_code: None if test is still running, otherwise the exit code
|
|
319
|
+
"""
|
|
309
320
|
if self.state is None or self.state.output_manager is None:
|
|
310
|
-
return "", None
|
|
321
|
+
return "", None, None
|
|
311
322
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
323
|
+
output_path, test_id, return_code = (
|
|
324
|
+
self.state.output_manager.get_current_output()
|
|
325
|
+
)
|
|
315
326
|
|
|
316
327
|
if output_path is None:
|
|
317
|
-
return "",
|
|
328
|
+
return "", None, None
|
|
318
329
|
|
|
319
330
|
# Read last 4KB of file
|
|
320
331
|
try:
|
|
@@ -326,9 +337,13 @@ class ReducerWorker:
|
|
|
326
337
|
else:
|
|
327
338
|
f.seek(0)
|
|
328
339
|
data = f.read()
|
|
329
|
-
return
|
|
340
|
+
return (
|
|
341
|
+
data.decode("utf-8", errors="replace"),
|
|
342
|
+
test_id,
|
|
343
|
+
return_code,
|
|
344
|
+
)
|
|
330
345
|
except OSError:
|
|
331
|
-
return "",
|
|
346
|
+
return "", test_id, return_code
|
|
332
347
|
|
|
333
348
|
def _get_content_preview(self) -> tuple[str, bool]:
|
|
334
349
|
"""Get a preview of the current test case content."""
|
|
@@ -379,6 +394,29 @@ class ReducerWorker:
|
|
|
379
394
|
return None
|
|
380
395
|
|
|
381
396
|
stats = self.problem.stats
|
|
397
|
+
runtime = time.time() - stats.start_time
|
|
398
|
+
current_size = stats.current_test_case_size
|
|
399
|
+
|
|
400
|
+
# Record size history when size changes or periodically
|
|
401
|
+
# Use 200ms interval for first 5 minutes, then 1s (ticks are at 1-minute intervals)
|
|
402
|
+
history_interval = 1.0 if runtime >= 300 else 0.2
|
|
403
|
+
|
|
404
|
+
if not self._size_history:
|
|
405
|
+
# First sample: record initial size at time 0
|
|
406
|
+
self._size_history.append((0.0, stats.initial_test_case_size))
|
|
407
|
+
self._last_recorded_size = stats.initial_test_case_size
|
|
408
|
+
self._last_history_time = 0.0
|
|
409
|
+
|
|
410
|
+
if current_size != self._last_recorded_size:
|
|
411
|
+
# Size changed - always record
|
|
412
|
+
self._size_history.append((runtime, current_size))
|
|
413
|
+
self._last_recorded_size = current_size
|
|
414
|
+
self._last_history_time = runtime
|
|
415
|
+
elif runtime - self._last_history_time >= history_interval:
|
|
416
|
+
# No size change but interval passed - record periodic update
|
|
417
|
+
self._size_history.append((runtime, current_size))
|
|
418
|
+
self._last_history_time = runtime
|
|
419
|
+
|
|
382
420
|
content_preview, hex_mode = self._get_content_preview()
|
|
383
421
|
|
|
384
422
|
# Get parallel workers count and track average
|
|
@@ -433,7 +471,13 @@ class ReducerWorker:
|
|
|
433
471
|
disabled_passes = []
|
|
434
472
|
|
|
435
473
|
# Get test output preview
|
|
436
|
-
test_output_preview, active_test_id =
|
|
474
|
+
test_output_preview, active_test_id, last_return_code = (
|
|
475
|
+
self._get_test_output_preview()
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
# Get new size history entries since last update
|
|
479
|
+
new_entries = self._size_history[self._last_sent_history_index :]
|
|
480
|
+
self._last_sent_history_index = len(self._size_history)
|
|
437
481
|
|
|
438
482
|
return ProgressUpdate(
|
|
439
483
|
status=self.reducer.status if self.reducer else "",
|
|
@@ -443,7 +487,7 @@ class ReducerWorker:
|
|
|
443
487
|
reductions=stats.reductions,
|
|
444
488
|
interesting_calls=stats.interesting_calls,
|
|
445
489
|
wasted_calls=stats.wasted_interesting_calls,
|
|
446
|
-
runtime=
|
|
490
|
+
runtime=runtime,
|
|
447
491
|
parallel_workers=parallel_workers,
|
|
448
492
|
average_parallelism=average_parallelism,
|
|
449
493
|
effective_parallelism=effective_parallelism,
|
|
@@ -455,6 +499,8 @@ class ReducerWorker:
|
|
|
455
499
|
disabled_passes=disabled_passes,
|
|
456
500
|
test_output_preview=test_output_preview,
|
|
457
501
|
active_test_id=active_test_id,
|
|
502
|
+
last_test_return_code=last_return_code,
|
|
503
|
+
new_size_history=new_entries,
|
|
458
504
|
)
|
|
459
505
|
|
|
460
506
|
async def emit_progress_updates(self) -> None:
|