shrinkray 26.1.1.0__tar.gz → 26.2.4.1__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.
Files changed (69) hide show
  1. {shrinkray-26.1.1.0/src/shrinkray.egg-info → shrinkray-26.2.4.1}/PKG-INFO +1 -1
  2. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/pyproject.toml +1 -1
  3. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/client.py +21 -1
  4. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/tui.py +70 -54
  5. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1/src/shrinkray.egg-info}/PKG-INFO +1 -1
  6. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_subprocess_client.py +120 -0
  7. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_tui.py +128 -31
  8. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/LICENSE +0 -0
  9. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/README.md +0 -0
  10. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/setup.cfg +0 -0
  11. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/__init__.py +0 -0
  12. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/__main__.py +0 -0
  13. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/cli.py +0 -0
  14. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/formatting.py +0 -0
  15. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/history.py +0 -0
  16. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/__init__.py +0 -0
  17. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/bytes.py +0 -0
  18. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/clangdelta.py +0 -0
  19. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/definitions.py +0 -0
  20. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/genericlanguages.py +0 -0
  21. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/json.py +0 -0
  22. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/patching.py +0 -0
  23. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/python.py +0 -0
  24. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/sat.py +0 -0
  25. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/passes/sequences.py +0 -0
  26. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/problem.py +0 -0
  27. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/process.py +0 -0
  28. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/py.typed +0 -0
  29. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/reducer.py +0 -0
  30. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/state.py +0 -0
  31. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/__init__.py +0 -0
  32. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/protocol.py +0 -0
  33. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/subprocess/worker.py +0 -0
  34. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/ui.py +0 -0
  35. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/validation.py +0 -0
  36. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray/work.py +0 -0
  37. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/SOURCES.txt +0 -0
  38. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/dependency_links.txt +0 -0
  39. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/entry_points.txt +0 -0
  40. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/requires.txt +0 -0
  41. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/src/shrinkray.egg-info/top_level.txt +0 -0
  42. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_byte_reduction_passes.py +0 -0
  43. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_clang_delta.py +0 -0
  44. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_cli.py +0 -0
  45. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_definitions.py +0 -0
  46. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_dimacs_cnf.py +0 -0
  47. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_formatting.py +0 -0
  48. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_generic_language.py +0 -0
  49. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_generic_shrinking_properties.py +0 -0
  50. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_history.py +0 -0
  51. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_json_passes.py +0 -0
  52. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_main.py +0 -0
  53. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_misc_reduction_performance.py +0 -0
  54. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_natural_sort_orders.py +0 -0
  55. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_patching.py +0 -0
  56. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_problem.py +0 -0
  57. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_process.py +0 -0
  58. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_python_reducers.py +0 -0
  59. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_reducer.py +0 -0
  60. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_reduction_passes.py +0 -0
  61. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_sat.py +0 -0
  62. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_state.py +0 -0
  63. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_subprocess_integration.py +0 -0
  64. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_subprocess_protocol.py +0 -0
  65. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_subprocess_worker.py +0 -0
  66. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_tui_snapshots.py +0 -0
  67. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_ui.py +0 -0
  68. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_validation.py +0 -0
  69. {shrinkray-26.1.1.0 → shrinkray-26.2.4.1}/tests/test_work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 26.1.1.0
3
+ Version: 26.2.4.1
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shrinkray"
3
- version = "26.1.1.0"
3
+ version = "26.2.4.1"
4
4
  description = "Shrink Ray"
5
5
  authors = [
6
6
  {name = "David R. MacIver", email = "david@drmaciver.com"}
@@ -27,6 +27,7 @@ class SubprocessClient:
27
27
  self._progress_queue: asyncio.Queue[ProgressUpdate] = asyncio.Queue()
28
28
  self._reader_task: asyncio.Task | None = None
29
29
  self._completed = False
30
+ self._closed = False
30
31
  self._error_message: str | None = None
31
32
  self._debug_mode = debug_mode
32
33
  self._stderr_log_file: IO[str] | None = None
@@ -261,6 +262,17 @@ class SubprocessClient:
261
262
 
262
263
  async def close(self) -> None:
263
264
  """Close the subprocess."""
265
+ if self._closed:
266
+ return
267
+ self._closed = True
268
+
269
+ # Cancel all pending futures first so any code awaiting send_command
270
+ # responses (e.g. cancel() in action_quit) is unblocked immediately.
271
+ for future in self._pending_responses.values():
272
+ if not future.done():
273
+ future.cancel()
274
+ self._pending_responses.clear()
275
+
264
276
  if self._reader_task is not None:
265
277
  self._reader_task.cancel()
266
278
  try:
@@ -278,13 +290,21 @@ class SubprocessClient:
278
290
  if self._process.returncode is None:
279
291
  try:
280
292
  self._process.terminate()
281
- await asyncio.wait_for(self._process.wait(), timeout=5.0)
293
+ await asyncio.wait_for(self._process.wait(), timeout=2.0)
282
294
  except TimeoutError:
283
295
  self._process.kill()
284
296
  await self._process.wait()
285
297
  except ProcessLookupError:
286
298
  pass # Process already exited
287
299
 
300
+ # Always await wait() to ensure the asyncio transport is fully
301
+ # finalized before the event loop closes. This prevents the
302
+ # "Event loop is closed" RuntimeError from BaseSubprocessTransport.__del__.
303
+ if self._process.returncode is not None:
304
+ await self._process.wait()
305
+ # Flush pending event loop callbacks (transport cleanup)
306
+ await asyncio.sleep(0)
307
+
288
308
  # Close and remove the stderr log file
289
309
  if self._stderr_log_file is not None:
290
310
  try:
@@ -13,7 +13,6 @@ from difflib import unified_diff
13
13
  from typing import Literal, Protocol, cast
14
14
 
15
15
  import humanize
16
- from rich.markup import escape as escape_markup
17
16
  from rich.text import Text
18
17
  from textual import work
19
18
  from textual.app import App, ComposeResult
@@ -46,6 +45,7 @@ from shrinkray.subprocess.protocol import (
46
45
 
47
46
  ThemeMode = Literal["auto", "dark", "light"]
48
47
 
48
+
49
49
  # Custom themes with true white/black backgrounds
50
50
  SHRINKRAY_LIGHT_THEME = Theme(
51
51
  name="shrinkray-light",
@@ -505,20 +505,20 @@ class ContentPreview(Static):
505
505
  # Fallback based on common terminal height
506
506
  return 30
507
507
 
508
- def render(self) -> str:
508
+ def render(self) -> Text:
509
509
  if not self.preview_content:
510
- return "Loading..."
510
+ return Text("Loading...")
511
511
 
512
512
  available_lines = self._get_available_lines()
513
513
 
514
514
  if self.hex_mode:
515
- return f"[Hex mode]\n{self.preview_content}"
515
+ return Text(f"[Hex mode]\n{self.preview_content}")
516
516
 
517
517
  lines = self.preview_content.split("\n")
518
518
 
519
519
  # For small files that fit, show full content
520
520
  if len(lines) <= available_lines:
521
- return self.preview_content
521
+ return Text(self.preview_content)
522
522
 
523
523
  # For larger files, show diff if we have previous displayed content
524
524
  if (
@@ -530,10 +530,10 @@ class ContentPreview(Static):
530
530
  diff = list(unified_diff(prev_lines, curr_lines, lineterm=""))
531
531
  if diff:
532
532
  # Show as much diff as fits
533
- return "\n".join(diff[:available_lines])
533
+ return Text("\n".join(diff[:available_lines]))
534
534
 
535
535
  # No diff available, show truncated content
536
- return (
536
+ return Text(
537
537
  "\n".join(lines[:available_lines])
538
538
  + f"\n\n... ({len(lines) - available_lines} more lines)"
539
539
  )
@@ -591,39 +591,46 @@ class OutputPreview(Static):
591
591
  pass
592
592
  return 30
593
593
 
594
- def render(self) -> str:
594
+ def render(self) -> Text:
595
595
  # Header line - use return_code to determine if test is running
596
596
  # (return_code is None means still running, has value means completed)
597
597
  if self.active_test_id is not None and self.last_return_code is None:
598
- header = f"[green]Test #{self.active_test_id} running...[/green]"
598
+ header_text = f"Test #{self.active_test_id} running..."
599
+ header_style = "green"
599
600
  elif self.active_test_id is not None:
600
- header = f"[dim]Test #{self.active_test_id} exited with code {self.last_return_code}[/dim]"
601
+ header_text = (
602
+ f"Test #{self.active_test_id} exited with code {self.last_return_code}"
603
+ )
604
+ header_style = "dim"
601
605
  elif self._has_seen_output or self.output_content:
602
606
  # Have seen output before - show without header
603
- header = ""
607
+ header_text = ""
608
+ header_style = ""
604
609
  else:
605
- header = "[dim]No test output yet...[/dim]"
610
+ header_text = "No test output yet..."
611
+ header_style = "dim"
606
612
 
607
613
  if not self.output_content:
608
- return header
614
+ return Text(header_text, style=header_style)
609
615
 
610
616
  available_lines = self._get_available_lines()
611
- # Escape the output content to prevent Rich markup interpretation
612
- # (test output may contain characters like ^ that have special meaning)
613
- escaped_content = escape_markup(self.output_content)
614
- lines = escaped_content.split("\n")
615
-
616
- # Build prefix (header + newline, or empty if no header)
617
- prefix = f"{header}\n" if header else ""
617
+ lines = self.output_content.split("\n")
618
618
 
619
619
  # Show tail of output (most recent lines)
620
- if len(lines) <= available_lines:
621
- return f"{prefix}{escaped_content}"
620
+ if len(lines) > available_lines:
621
+ truncated_lines = lines[-(available_lines):]
622
+ skipped = len(lines) - available_lines
623
+ content = f"... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
624
+ else:
625
+ content = self.output_content
622
626
 
623
- # Truncate from the beginning
624
- truncated_lines = lines[-(available_lines):]
625
- skipped = len(lines) - available_lines
626
- return f"{prefix}... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
627
+ # Build result with styled header and unstyled content
628
+ result = Text()
629
+ if header_text:
630
+ result.append(header_text, style=header_style)
631
+ result.append("\n")
632
+ result.append(content)
633
+ return result
627
634
 
628
635
 
629
636
  class HelpScreen(ModalScreen[None]):
@@ -734,21 +741,20 @@ class ExpandedBoxModal(ModalScreen[None]):
734
741
  self._content_widget_id = content_widget_id
735
742
  self._file_path = file_path
736
743
 
737
- def _read_file(self, file_path: str) -> str:
744
+ def _read_file(self, file_path: str) -> Text:
738
745
  """Read file content, decoding as text if possible."""
739
746
  if not os.path.isfile(file_path):
740
- return "[dim]File not found[/dim]"
747
+ return Text("File not found", style="dim")
741
748
  try:
742
749
  with open(file_path, "rb") as f:
743
750
  raw_content = f.read()
744
751
  # Try to decode as text, fall back to hex display if binary
745
752
  encoding, text = try_decode(raw_content)
746
753
  if encoding is not None:
747
- # Escape Rich markup to prevent interpretation of [ ] etc
748
- return escape_markup(text)
749
- return "[Binary content - hex display]\n\n" + raw_content.hex()
754
+ return Text(text)
755
+ return Text("Binary content - hex display\n\n" + raw_content.hex())
750
756
  except OSError:
751
- return "[red]Error reading file[/red]"
757
+ return Text("Error reading file", style="red")
752
758
 
753
759
  def compose(self) -> ComposeResult:
754
760
  with Vertical():
@@ -781,16 +787,16 @@ class ExpandedBoxModal(ModalScreen[None]):
781
787
  return "Statistics not available"
782
788
  return stats_displays[0].render()
783
789
 
784
- def _get_file_content(self, app: "ShrinkRayApp") -> str:
790
+ def _get_file_content(self, app: "ShrinkRayApp") -> str | Text:
785
791
  """Get content from file or preview widget."""
786
792
  if self._file_path:
787
793
  return self._read_file(self._file_path)
788
794
  content_previews = list(app.query("#content-preview").results(ContentPreview))
789
795
  if not content_previews:
790
796
  return "Content preview not available"
791
- return content_previews[0].preview_content
797
+ return Text(content_previews[0].preview_content)
792
798
 
793
- def _get_output_content(self, app: "ShrinkRayApp") -> str:
799
+ def _get_output_content(self, app: "ShrinkRayApp") -> str | Text:
794
800
  """Get output content from the output preview widget."""
795
801
  output_previews = list(app.query("#output-preview").results(OutputPreview))
796
802
  if not output_previews:
@@ -811,21 +817,29 @@ class ExpandedBoxModal(ModalScreen[None]):
811
817
  )
812
818
  has_seen_output = output_preview._has_seen_output
813
819
 
814
- # Build header - return_code is None means test is still running
820
+ # Build header with styling
815
821
  if test_id is not None and return_code is None:
816
- header = f"[green]Test #{test_id} running...[/green]\n\n"
822
+ header_text = f"Test #{test_id} running..."
823
+ header_style = "green"
817
824
  elif test_id is not None:
818
- header = f"[dim]Test #{test_id} exited with code {return_code}[/dim]\n\n"
825
+ header_text = f"Test #{test_id} exited with code {return_code}"
826
+ header_style = "dim"
819
827
  else:
820
- header = ""
828
+ header_text = ""
829
+ header_style = ""
821
830
 
822
831
  if raw_content:
823
- return header + raw_content
832
+ result = Text()
833
+ if header_text:
834
+ result.append(header_text, style=header_style)
835
+ result.append("\n\n")
836
+ result.append(raw_content)
837
+ return result
824
838
  elif has_seen_output or test_id is not None:
825
839
  # We've seen output before - show header only (no "No test output" message)
826
- return header.rstrip("\n") if header else ""
840
+ return Text(header_text, style=header_style)
827
841
  else:
828
- return "[dim]No test output yet...[/dim]"
842
+ return Text("No test output yet...", style="dim")
829
843
 
830
844
  def on_mount(self) -> None:
831
845
  """Populate content from the source widget."""
@@ -1447,13 +1461,13 @@ class HistoryExplorerModal(ModalScreen[None]):
1447
1461
  if os.path.isfile(output_path):
1448
1462
  output_content = self._read_file(output_path)
1449
1463
  else:
1450
- output_content = "[dim]No output captured[/dim]"
1464
+ output_content = Text("No output captured", style="dim")
1451
1465
  self.query_one(f"#{output_preview_id}", Static).update(output_content)
1452
1466
 
1453
- def _read_file(self, file_path: str) -> str:
1467
+ def _read_file(self, file_path: str) -> Text:
1454
1468
  """Read file content, decoding as text if possible."""
1455
1469
  if not os.path.isfile(file_path):
1456
- return "[dim]File not found[/dim]"
1470
+ return Text("File not found", style="dim")
1457
1471
  try:
1458
1472
  with open(file_path, "rb") as f:
1459
1473
  raw_content = f.read()
@@ -1465,18 +1479,17 @@ class HistoryExplorerModal(ModalScreen[None]):
1465
1479
  # Try to decode as text
1466
1480
  encoding, text = try_decode(raw_content)
1467
1481
  if encoding is not None:
1468
- # Escape Rich markup to prevent interpretation of [ ] etc
1469
- text = escape_markup(text)
1482
+ result = Text(text)
1470
1483
  if truncated:
1471
- text += "\n\n[dim]... (truncated)[/dim]"
1472
- return text
1484
+ result.append("\n\n... (truncated)", style="dim")
1485
+ return result
1473
1486
  # Binary content - hex display
1474
- hex_display = "[Binary content - hex display]\n\n" + raw_content.hex()
1487
+ result = Text("Binary content - hex display\n\n" + raw_content.hex())
1475
1488
  if truncated:
1476
- hex_display += "\n\n[dim]... (truncated)[/dim]"
1477
- return hex_display
1489
+ result.append("\n\n... (truncated)", style="dim")
1490
+ return result
1478
1491
  except OSError:
1479
- return "[red]Error reading file[/red]"
1492
+ return Text("Error reading file", style="red")
1480
1493
 
1481
1494
  def action_restart_from_here(self) -> None:
1482
1495
  """Restart reduction from the currently selected history point."""
@@ -1925,11 +1938,14 @@ class ShrinkRayApp(App[None]):
1925
1938
 
1926
1939
  async def action_quit(self) -> None:
1927
1940
  """Quit the application with graceful cancellation."""
1941
+ self.update_status("Shutting down...")
1928
1942
  if self._client and not self._completed:
1929
1943
  try:
1930
- await self._client.cancel()
1944
+ await self._client.close()
1931
1945
  except Exception:
1932
1946
  pass # Process may have already exited
1947
+ # Prevent double-close in run_reduction's finally block
1948
+ self._client = None
1933
1949
  self.exit()
1934
1950
 
1935
1951
  def action_show_pass_stats(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 26.1.1.0
3
+ Version: 26.2.4.1
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -1120,3 +1120,123 @@ def test_subprocess_client_close_handles_stderr_log_file_exception():
1120
1120
  mock_file.close.assert_called_once()
1121
1121
 
1122
1122
  asyncio.run(run())
1123
+
1124
+
1125
+ def test_subprocess_client_close_cancels_pending_futures():
1126
+ """Test close() cancels all pending futures so awaiting code is unblocked."""
1127
+
1128
+ async def run():
1129
+ client = SubprocessClient()
1130
+
1131
+ # Create pending futures
1132
+ loop = asyncio.get_event_loop()
1133
+ future1: asyncio.Future[Response] = loop.create_future()
1134
+ future2: asyncio.Future[Response] = loop.create_future()
1135
+ client._pending_responses["req-1"] = future1
1136
+ client._pending_responses["req-2"] = future2
1137
+
1138
+ await client.close()
1139
+
1140
+ # Both futures should be cancelled
1141
+ assert future1.cancelled()
1142
+ assert future2.cancelled()
1143
+ # Pending responses should be cleared
1144
+ assert len(client._pending_responses) == 0
1145
+
1146
+ asyncio.run(run())
1147
+
1148
+
1149
+ def test_subprocess_client_close_is_idempotent():
1150
+ """Test close() is a no-op on second call due to _closed flag."""
1151
+
1152
+ async def run():
1153
+ client = SubprocessClient()
1154
+
1155
+ # Create a pending future
1156
+ loop = asyncio.get_event_loop()
1157
+ future: asyncio.Future[Response] = loop.create_future()
1158
+ client._pending_responses["req-1"] = future
1159
+
1160
+ await client.close()
1161
+ assert future.cancelled()
1162
+ assert client._closed
1163
+
1164
+ # Add another future after close (simulating a race)
1165
+ future2: asyncio.Future[Response] = loop.create_future()
1166
+ client._pending_responses["req-2"] = future2
1167
+
1168
+ # Second close should be a no-op
1169
+ await client.close()
1170
+
1171
+ # The second future should NOT be cancelled (close was a no-op)
1172
+ assert not future2.cancelled()
1173
+
1174
+ asyncio.run(run())
1175
+
1176
+
1177
+ def test_subprocess_client_close_awaits_wait_after_process_exit():
1178
+ """Test close() always awaits process.wait() after process exits."""
1179
+
1180
+ async def run():
1181
+ client = SubprocessClient()
1182
+
1183
+ # Create a mock process that has already exited
1184
+ mock_process = MagicMock()
1185
+ mock_process.returncode = 0 # Already exited
1186
+ mock_process.stdin = MagicMock()
1187
+ mock_process.stdin.close = MagicMock()
1188
+
1189
+ wait_called = [False]
1190
+
1191
+ async def mock_wait():
1192
+ wait_called[0] = True
1193
+
1194
+ mock_process.wait = mock_wait
1195
+ client._process = mock_process
1196
+ client._reader_task = None
1197
+
1198
+ await client.close()
1199
+
1200
+ # wait() should have been called even though process already exited
1201
+ assert wait_called[0]
1202
+
1203
+ asyncio.run(run())
1204
+
1205
+
1206
+ def test_subprocess_client_close_skips_done_futures():
1207
+ """Test close() skips already-done futures when cancelling."""
1208
+
1209
+ async def run():
1210
+ client = SubprocessClient()
1211
+
1212
+ # Create one done and one pending future
1213
+ loop = asyncio.get_event_loop()
1214
+ done_future: asyncio.Future[Response] = loop.create_future()
1215
+ done_future.set_result(Response(id="done", result={"status": "ok"}))
1216
+ pending_future: asyncio.Future[Response] = loop.create_future()
1217
+
1218
+ client._pending_responses["done"] = done_future
1219
+ client._pending_responses["pending"] = pending_future
1220
+
1221
+ await client.close()
1222
+
1223
+ # Done future should still have its result (not cancelled)
1224
+ assert not done_future.cancelled()
1225
+ assert done_future.result().result == {"status": "ok"}
1226
+ # Pending future should be cancelled
1227
+ assert pending_future.cancelled()
1228
+
1229
+ asyncio.run(run())
1230
+
1231
+
1232
+ def test_subprocess_client_close_handles_stderr_log_unlink_exception():
1233
+ """Test close() handles exception when unlinking stderr log file."""
1234
+
1235
+ async def run():
1236
+ client = SubprocessClient()
1237
+ client._stderr_log_path = "/nonexistent/path/that/will/fail"
1238
+
1239
+ # Should not raise even if unlink fails
1240
+ await client.close()
1241
+
1242
+ asyncio.run(run())
@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
9
9
  from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
10
10
 
11
11
  import pytest
12
+ from rich.text import Text
12
13
  from textual.app import App
13
14
  from textual.widgets import DataTable, Label, ListView, Static, TabbedContent
14
15
 
@@ -112,6 +113,7 @@ class FakeReductionClient:
112
113
 
113
114
  async def close(self) -> None:
114
115
  self._closed = True
116
+ self._cancelled = True
115
117
 
116
118
  async def get_progress_updates(self) -> AsyncGenerator[ProgressUpdate, None]:
117
119
  for update in self._updates:
@@ -715,6 +717,40 @@ def test_output_preview_get_available_lines_app_zero_height():
715
717
  assert lines == 30
716
718
 
717
719
 
720
+ def test_output_preview_render_returns_text():
721
+ """Test that OutputPreview.render() returns a Text object, not a markup string.
722
+
723
+ Returning a Text object ensures Textual never parses user content as markup.
724
+ Previously, render() returned a markup string and used Rich's escape() which
725
+ failed on patterns like [key=; causing MarkupError.
726
+
727
+ This fixes the crash from: shrinkray --parallelism 60 interest2.sh bf_simple_yk.c
728
+ """
729
+ widget = OutputPreview()
730
+ # Content that would crash if parsed as markup
731
+ widget.output_content = "some output\n[key=;value"
732
+ widget.active_test_id = 1
733
+
734
+ rendered = widget.render()
735
+ assert isinstance(rendered, Text)
736
+ assert "[key=;value" in rendered
737
+
738
+
739
+ def test_content_preview_render_returns_text():
740
+ """Test that ContentPreview.render() returns a Text object, not a markup string.
741
+
742
+ Returning a Text object ensures Textual never parses user content as markup.
743
+ Previously, render() returned raw strings that would crash on patterns like
744
+ [Key=; when Textual parsed them as markup.
745
+ """
746
+ widget = ContentPreview()
747
+ widget.update_content("[Key=;value", False)
748
+
749
+ rendered = widget.render()
750
+ assert isinstance(rendered, Text)
751
+ assert "[Key=;value" in rendered
752
+
753
+
718
754
  # === ShrinkRayApp with fake client tests ===
719
755
 
720
756
 
@@ -1303,21 +1339,22 @@ def test_expanded_modal_read_file_success(tmp_path):
1303
1339
 
1304
1340
  modal = ExpandedBoxModal("Test", "content-container")
1305
1341
  content = modal._read_file(str(test_file))
1306
- assert content == "Hello World"
1342
+ assert isinstance(content, Text)
1343
+ assert content.plain == "Hello World"
1307
1344
 
1308
1345
 
1309
- def test_expanded_modal_read_file_escapes_markup(tmp_path):
1310
- """Test _read_file escapes Rich markup characters like [."""
1346
+ def test_expanded_modal_read_file_preserves_brackets(tmp_path):
1347
+ """Test _read_file preserves bracket characters in file content."""
1311
1348
  test_file = tmp_path / "test.txt"
1312
1349
  # Content with brackets that could be interpreted as Rich markup
1313
1350
  test_file.write_text("expected [bold] and [red]text[/red]")
1314
1351
 
1315
1352
  modal = ExpandedBoxModal("Test", "content-container")
1316
1353
  content = modal._read_file(str(test_file))
1317
- # Opening brackets should be escaped to prevent Rich interpretation
1318
- # Rich's escape() converts [ to \[ when followed by tag-like content
1319
- assert "[bold]" not in content or "\\[bold]" in content
1320
- assert "[red]" not in content or "\\[red]" in content
1354
+ # Returns a Text object so brackets are preserved literally
1355
+ assert isinstance(content, Text)
1356
+ assert "[bold]" in content.plain
1357
+ assert "[red]text[/red]" in content.plain
1321
1358
 
1322
1359
 
1323
1360
  def test_expanded_modal_read_file_binary(tmp_path):
@@ -1328,26 +1365,29 @@ def test_expanded_modal_read_file_binary(tmp_path):
1328
1365
 
1329
1366
  modal = ExpandedBoxModal("Test", "content-container")
1330
1367
  content = modal._read_file(str(test_file))
1368
+ assert isinstance(content, Text)
1331
1369
  assert "Binary content" in content
1332
1370
  assert "80818283" in content
1333
1371
 
1334
1372
 
1335
1373
  def test_expanded_modal_read_file_missing(tmp_path):
1336
- """Test _read_file returns error message for missing file."""
1374
+ """Test _read_file returns styled message for missing file."""
1337
1375
  modal = ExpandedBoxModal("Test", "content-container")
1338
1376
  result = modal._read_file(str(tmp_path / "nonexistent.txt"))
1339
- assert "[dim]File not found[/dim]" in result
1377
+ assert isinstance(result, Text)
1378
+ assert "File not found" in result
1340
1379
 
1341
1380
 
1342
1381
  def test_expanded_modal_read_file_oserror(tmp_path):
1343
- """Test _read_file returns error message on OSError."""
1382
+ """Test _read_file returns styled message on OSError."""
1344
1383
  modal = ExpandedBoxModal("Test", "content-container")
1345
1384
  # Create a file that exists but can't be read
1346
1385
  test_file = tmp_path / "unreadable.txt"
1347
1386
  test_file.write_text("content")
1348
1387
  with patch("builtins.open", side_effect=OSError("Permission denied")):
1349
1388
  result = modal._read_file(str(test_file))
1350
- assert "[red]Error reading file[/red]" in result
1389
+ assert isinstance(result, Text)
1390
+ assert "Error reading file" in result
1351
1391
 
1352
1392
 
1353
1393
  # === ExpandedBoxModal integration tests ===
@@ -5367,11 +5407,60 @@ def test_expanded_modal_output_with_empty_content_and_header():
5367
5407
  asyncio.run(run())
5368
5408
 
5369
5409
 
5410
+ def test_expanded_modal_output_content_without_test_id():
5411
+ """Test output modal with content but no test ID (no header)."""
5412
+
5413
+ async def run():
5414
+ fake_client = FakeReductionClient(updates=[], wait_indefinitely=True)
5415
+
5416
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
5417
+ f.write("test")
5418
+ temp_file = f.name
5419
+
5420
+ try:
5421
+ app = ShrinkRayApp(
5422
+ file_path=temp_file,
5423
+ test=["true"],
5424
+ exit_on_completion=False,
5425
+ client=fake_client,
5426
+ )
5427
+
5428
+ async with app.run_test() as pilot:
5429
+ await pilot.pause()
5430
+
5431
+ # Set output preview with content but no test ID
5432
+ output_preview = app.query_one("#output-preview", OutputPreview)
5433
+ output_preview.active_test_id = None
5434
+ output_preview._has_seen_output = True
5435
+ output_preview.output_content = "some raw output"
5436
+ output_preview._pending_content = "some raw output"
5437
+ output_preview._pending_test_id = None
5438
+
5439
+ # Create modal for output-container
5440
+ modal = ExpandedBoxModal("Output", "output-container", None)
5441
+ await app.push_screen(modal)
5442
+ await pilot.pause()
5443
+
5444
+ # Should show content without header
5445
+ content = modal.query_one("#expanded-content", Static)
5446
+ content_str = get_static_content(content)
5447
+ assert "some raw output" in content_str
5448
+ assert "Test #" not in content_str
5449
+
5450
+ await pilot.press("escape")
5451
+ await pilot.press("q")
5452
+ finally:
5453
+ if os.path.exists(temp_file):
5454
+ os.unlink(temp_file)
5455
+
5456
+ asyncio.run(run())
5457
+
5458
+
5370
5459
  # === Action method tests ===
5371
5460
 
5372
5461
 
5373
5462
  def test_action_quit_with_client():
5374
- """Test action_quit cancels client before exiting."""
5463
+ """Test action_quit closes client directly before exiting."""
5375
5464
 
5376
5465
  async def run():
5377
5466
  fake_client = FakeReductionClient(updates=[], wait_indefinitely=True)
@@ -5395,8 +5484,10 @@ def test_action_quit_with_client():
5395
5484
  await pilot.press("q")
5396
5485
  await pilot.pause()
5397
5486
 
5398
- # Client should have been cancelled
5399
- assert fake_client._cancelled
5487
+ # Client should have been closed directly (not just cancelled)
5488
+ assert fake_client._closed
5489
+ # Client reference should be cleared to prevent double-close
5490
+ assert app._client is None
5400
5491
  finally:
5401
5492
  if os.path.exists(temp_file):
5402
5493
  os.unlink(temp_file)
@@ -5405,16 +5496,16 @@ def test_action_quit_with_client():
5405
5496
 
5406
5497
 
5407
5498
  def test_action_quit_handles_exception():
5408
- """Test action_quit handles exception from cancel gracefully."""
5499
+ """Test action_quit handles exception from close gracefully."""
5409
5500
 
5410
5501
  async def run():
5411
5502
  fake_client = FakeReductionClient(updates=[], wait_indefinitely=True)
5412
5503
 
5413
- # Make cancel raise an exception
5504
+ # Make close raise an exception
5414
5505
  async def raise_exception():
5415
5506
  raise RuntimeError("Process already exited")
5416
5507
 
5417
- fake_client.cancel = raise_exception
5508
+ fake_client.close = raise_exception
5418
5509
 
5419
5510
  with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
5420
5511
  f.write("test")
@@ -6125,11 +6216,12 @@ def test_history_modal_read_file_success(tmp_path):
6125
6216
  modal = HistoryExplorerModal(str(tmp_path), "test.txt")
6126
6217
  content = modal._read_file(str(test_file))
6127
6218
 
6128
- assert content == "Hello World\nLine 2"
6219
+ assert isinstance(content, Text)
6220
+ assert content.plain == "Hello World\nLine 2"
6129
6221
 
6130
6222
 
6131
- def test_history_modal_read_file_escapes_markup(tmp_path):
6132
- """Test _read_file escapes Rich markup characters like [."""
6223
+ def test_history_modal_read_file_preserves_brackets(tmp_path):
6224
+ """Test _read_file preserves bracket characters in file content."""
6133
6225
  test_file = tmp_path / "test.txt"
6134
6226
  # Content with brackets that look like Rich markup tags
6135
6227
  test_file.write_text("error: [bold]text[/bold] failed")
@@ -6137,9 +6229,9 @@ def test_history_modal_read_file_escapes_markup(tmp_path):
6137
6229
  modal = HistoryExplorerModal(str(tmp_path), "test.txt")
6138
6230
  content = modal._read_file(str(test_file))
6139
6231
 
6140
- # Opening brackets should be escaped to prevent Rich interpretation
6141
- assert "[bold]" not in content or "\\[bold]" in content
6142
- # The original text should not be corrupted
6232
+ # Returns a Text object so brackets are preserved literally
6233
+ assert isinstance(content, Text)
6234
+ assert "[bold]" in content.plain
6143
6235
  assert "error:" in content
6144
6236
  assert "failed" in content
6145
6237
 
@@ -6153,15 +6245,17 @@ def test_history_modal_read_file_binary(tmp_path):
6153
6245
  content = modal._read_file(str(test_file))
6154
6246
 
6155
6247
  # Should fall back to hex display
6248
+ assert isinstance(content, Text)
6156
6249
  assert "Binary content" in content
6157
6250
 
6158
6251
 
6159
6252
  def test_history_modal_read_file_missing(tmp_path):
6160
- """Test _read_file returns error message for missing file."""
6253
+ """Test _read_file returns styled message for missing file."""
6161
6254
  modal = HistoryExplorerModal(str(tmp_path), "test.txt")
6162
6255
  content = modal._read_file(str(tmp_path / "nonexistent.txt"))
6163
6256
 
6164
- assert "[dim]File not found[/dim]" in content
6257
+ assert isinstance(content, Text)
6258
+ assert "File not found" in content
6165
6259
 
6166
6260
 
6167
6261
  def test_history_modal_read_file_truncated_text(tmp_path):
@@ -6174,9 +6268,10 @@ def test_history_modal_read_file_truncated_text(tmp_path):
6174
6268
  content = modal._read_file(str(test_file))
6175
6269
 
6176
6270
  # Should be truncated
6177
- assert "[dim]... (truncated)[/dim]" in content
6178
- # Content should be limited
6179
- assert len(content) < 60000
6271
+ assert isinstance(content, Text)
6272
+ assert "truncated" in content
6273
+ # Content should be limited (50000 chars + truncation message)
6274
+ assert len(content.plain) < 60000
6180
6275
 
6181
6276
 
6182
6277
  def test_history_modal_read_file_truncated_binary(tmp_path):
@@ -6189,8 +6284,9 @@ def test_history_modal_read_file_truncated_binary(tmp_path):
6189
6284
  content = modal._read_file(str(test_file))
6190
6285
 
6191
6286
  # Should be truncated and shown as binary
6192
- assert "[Binary content" in content
6193
- assert "[dim]... (truncated)[/dim]" in content
6287
+ assert isinstance(content, Text)
6288
+ assert "Binary content" in content
6289
+ assert "truncated" in content
6194
6290
 
6195
6291
 
6196
6292
  def test_history_modal_read_file_oserror(tmp_path):
@@ -6204,7 +6300,8 @@ def test_history_modal_read_file_oserror(tmp_path):
6204
6300
  with patch("builtins.open", side_effect=OSError("Permission denied")):
6205
6301
  content = modal._read_file(str(test_file))
6206
6302
 
6207
- assert "[red]Error reading file[/red]" in content
6303
+ assert isinstance(content, Text)
6304
+ assert "Error reading file" in content
6208
6305
 
6209
6306
 
6210
6307
  def test_history_modal_on_list_view_highlighted_no_entries(tmp_path):
File without changes
File without changes
File without changes