From c8c951bcda1659b59ca1cc33b6c576cc5d660a35 Mon Sep 17 00:00:00 2001 From: kuddev Date: Mon, 21 Sep 2026 17:05:43 +0800 Subject: [PATCH] fix(conformance): wait for split shell startup before column probes The resize fixture submitted the new pane's command before its shell produced initial output. Reuse the bounded boot precondition before the first sibling probe while preserving immediate post-resize measurements and strict geometry assertions. Cover delayed and silent startup with cleanup. --- .../2026-09-21-split-shell-startup.md | 66 +++++++++++++++++++ scripts/conformance/cases/resize.py | 9 +++ scripts/conformance/tests/test_resize.py | 45 ++++++++++++- 3 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 architecture/notes/scripts/conformance/2026-09-21-split-shell-startup.md 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])