diff --git a/architecture/notes/scripts/conformance/2026-09-21-split-shell-startup.md b/architecture/notes/scripts/conformance/2026-09-21-split-shell-startup.md new file mode 100644 index 0000000..ea7b187 --- /dev/null +++ b/architecture/notes/scripts/conformance/2026-09-21-split-shell-startup.md @@ -0,0 +1,66 @@ +# Wait for split-shell startup before column probes + +## Status + +Proposed for review with the conformance regression fix. + +## Context + +The resize case opens a sibling pane and queries both shells for their native +column counts. Creating the pane returns before the new shell has initialized. +The existing boot case already waits for initial shell output before submitting +commands, but the resize fixture did not apply that precondition to its sibling. + +## Evidence + +[Windows job 106259683240](https://github.com/Kuddev/pebrel/actions/runs/35576551914/job/106259683240) +recorded the source pane's initial width as 38. The sibling produced a late +PowerShell prompt and only the leading `W` of its column probe. There was no +completed sibling measurement, so the case never reached `pane.resize`. + +The focused regression simulates a sibling whose first output arrives after two +empty observations. Before the fix, its column command is submitted during +startup. A second regression requires a permanently silent sibling to fail and +be closed without performing the resize. + +## Decision + +Apply the existing boot fixture's bounded initial-output check before the new +sibling's first column command. Preserve the source pane's immediate initial +measurement and the existing startup timeout. This wait belongs to shell startup, +before the requested ratio change. + +Both post-resize commands still run without additional polling for geometry. +Their output must satisfy the original growth, shrinkage and total-column bounds. +Initial output alone never counts as a successful column measurement. + +## Rejected alternatives + +- Retry the full resize case until it passes: that can hide a geometry defect. +- Delay or retry column measurements after changing the ratio: that weakens the + immediate resize contract rather than addressing the uninitialized fixture. +- Increase measurement timeouts or accept missing probe output: neither makes an + input command submitted during startup execute reliably. + +## Consequences + +Only the test fixture gains an initialization precondition. Product input rules, +the runtime API, geometry assertions and report fields retain their contracts. +The initial-output check follows the harness's managed shell setup; it is not a +general claim that every interactive profile is ready after its first output. + +## Validation + +Regression coverage includes delayed and silent startup, growth and shrinkage, +stale widths, total-column bounds, failure diagnostics and sibling cleanup. The +fixture rejects startup polling after the resize, preserving the timing boundary. +The real Windows conformance job must pass before the candidate is accepted. + +## Supersedes + +None. + +## Revisit when + +The runtime exposes an explicit shell-input readiness signal that the conformance +fixture can use in place of its existing initial-output precondition. diff --git a/scripts/conformance/cases/resize.py b/scripts/conformance/cases/resize.py index e4ec1d1..f8c91f9 100644 --- a/scripts/conformance/cases/resize.py +++ b/scripts/conformance/cases/resize.py @@ -75,6 +75,15 @@ def run(ctx: ConformanceContext) -> dict[str, object]: try: source_before = ctx.measure_columns(source) measurements["source_columns_before"] = source_before + # Creating a pane precedes shell startup. Use the same initial-output + # precondition as boot before sending the sibling's first command. + # Measurements after pane.resize remain immediate and strict. + ctx.poll( + lambda: ctx.read(sibling), + lambda value: bool(value.get("text", "").strip()), + "split shell did not render initial output", + timeout=ctx.startup_timeout, + ) sibling_before = ctx.measure_columns(sibling) measurements["sibling_columns_before"] = sibling_before resized = ctx.api( diff --git a/scripts/conformance/tests/test_resize.py b/scripts/conformance/tests/test_resize.py index c066cf6..ba7c5a3 100644 --- a/scripts/conformance/tests/test_resize.py +++ b/scripts/conformance/tests/test_resize.py @@ -14,7 +14,7 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) from conformance.cases import resize -from conformance.harness import ConformanceError +from conformance.harness import ConformanceContext, ConformanceError class ResizeContext: @@ -23,15 +23,23 @@ class ResizeContext: shell = "bash" platform_family = "macos" - def __init__(self, artifact_dir: Path, columns: list[int]) -> None: + def __init__(self, artifact_dir: Path, columns: list[int], outputs=None) -> None: self.artifact_dir = artifact_dir self.columns = iter(columns) self.cleanup: list[str] = [] + self.startup_timeout = 3.0 + self.outputs = list(outputs) if outputs is not None else ["fixture prompt"] + self.sibling_ready = outputs is None + self.read_count = 0 + self.measured_panes: list[int] = [] + self.resized = False + self.poll = ConformanceContext.poll.__get__(self) def api(self, method: str, params=None, **kwargs): if method == "pane.split": return {"action": {"pane_id": 11}} if method == "pane.resize": + self.resized = True return {"action": {"ratio": params["ratio"]}} if method == "runtime.snapshot": return {"windows": [{"id": self.window_id}]} @@ -40,13 +48,46 @@ class ResizeContext: raise AssertionError(method) def measure_columns(self, pane_id: int) -> int: + if pane_id == 11 and not self.sibling_ready: + raise AssertionError("columns probe sent before sibling startup") + self.measured_panes.append(pane_id) return next(self.columns) + def read(self, pane_id: int): + if self.resized: + raise AssertionError("startup polling must not delay post-resize measurements") + self.read_count += 1 + text = self.outputs[0] + if len(self.outputs) > 1: + self.outputs.pop(0) + self.sibling_ready = bool(text.strip()) + return {"text": text} + def best_effort_api(self, method: str, params) -> None: self.cleanup.append(method) class ResizeDiagnosticsTests(unittest.TestCase): + def test_cold_sibling_renders_output_before_its_first_columns_command(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [57, 56, 80, 33], ["", " \n", "PS C:\\fixture> "]) + with patch("time.sleep"), patch("time.monotonic", side_effect=[0, 0.1, 0.2, 0.3]): + result = resize.run(ctx) + self.assertTrue(result["pty_columns_changed"]) + self.assertEqual(ctx.read_count, 3) + self.assertEqual(ctx.measured_panes, [10, 11, 10, 11]) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_silent_sibling_fails_before_resize_and_is_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [57], [""]) + with patch("time.sleep"), patch("time.monotonic", side_effect=range(20)): + with self.assertRaisesRegex(ConformanceError, "split shell did not render initial output"): + resize.run(ctx) + self.assertFalse(ctx.resized) + self.assertEqual(ctx.measured_panes, [10]) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + def test_growing_and_shrinking_panes_pass_without_diagnostic_sampling(self) -> None: with tempfile.TemporaryDirectory() as directory: ctx = ResizeContext(Path(directory), [57, 56, 80, 33])