From 270d23c106ba16d435e343a7b72f60ff34f31bb1 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 6 Sep 2026 20:38:20 +0800 Subject: [PATCH] test(conformance): retain geometry evidence for native resize failures Keep resize assertions strict while preserving before/after observations, bounded prepaint traces, and Linux kernel PTY geometry after a failure. Cover diagnostic failures and cleanup; retain dependency caches when later conformance checks fail. --- .github/workflows/preview-packages.yml | 17 +++ nebula_app/src/gpui_shell/terminal/element.rs | 23 ++++ scripts/conformance/cases/resize.py | 70 ++++++++++ scripts/conformance/tests/test_resize.py | 123 ++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 scripts/conformance/tests/test_resize.py diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index 614e12c..1c387e8 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -104,6 +104,7 @@ jobs: scripts.tests.test_app_icons scripts.tests.test_git_attributes scripts.conformance.tests.test_harness + scripts.conformance.tests.test_resize - name: Compile Python helpers run: python3 -m compileall -q scripts/preview_release.py scripts/conformance @@ -143,6 +144,8 @@ jobs: toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Test workspace natively run: cargo test --locked --workspace @@ -187,6 +190,8 @@ jobs: tar -tzf "$archive" "$root/usr/share/applications/io.github.kuddev.nebula.preview.desktop" - name: Run AppImage conformance under X11 + env: + NEBULA_RESIZE_TRACE: "1" run: | xvfb-run -a -s "-screen 0 1600x1000x24" \ python3 scripts/conformance/run.py \ @@ -195,6 +200,8 @@ jobs: --output dist/conformance/linux-appimage-report.json - name: Run AppImage conformance under Wayland + env: + NEBULA_RESIZE_TRACE: "1" run: | runtime_directory="$RUNNER_TEMP/nebula-wayland-runtime" mkdir -p "$runtime_directory" dist/conformance @@ -235,6 +242,8 @@ jobs: --output dist/conformance/linux-appimage-wayland-report.json - name: Install and run Debian package conformance under X11 + env: + NEBULA_RESIZE_TRACE: "1" run: | sudo apt-get install -y \ "./dist/NebulaTerminal-v$ASSET_VERSION-linux-x86_64.deb" @@ -298,6 +307,8 @@ jobs: toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Test workspace natively run: cargo test --locked --workspace @@ -371,6 +382,8 @@ jobs: --force "${signing_args[@]}" - name: Mount final DMG and run conformance + env: + NEBULA_RESIZE_TRACE: "1" run: | dmg="dist/NebulaTerminal-v$ASSET_VERSION-macos-${{ matrix.architecture }}.dmg" mount_point="$RUNNER_TEMP/nebula-preview-${{ matrix.architecture }}" @@ -436,6 +449,8 @@ jobs: with: toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Test workspace natively run: cargo test --locked --workspace - name: Test SSH confirmation and password dialogs @@ -443,6 +458,8 @@ jobs: - name: Build GPUI comparison binary run: cargo build --locked --release -p nebula --bin nebula --features gpui-shell - name: Run Windows conformance + env: + NEBULA_RESIZE_TRACE: "1" run: >- python scripts/conformance/run.py --app target/release/nebula.exe --platform windows-x86_64 --output dist/conformance/windows-report.json diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 9e0f0e3..3fb0356 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -226,6 +226,29 @@ impl Element for TerminalElement { let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); let view = self.view.read(cx); + // Bounded, opt-in evidence that a real layout reached this pane. PTY + // resize traces alone cannot distinguish missing prepaint from a + // viewport which still matches the spawn grid. + static TRACE_ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + static TRACE_SAMPLES: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + if *TRACE_ENABLED.get_or_init(|| std::env::var_os("NEBULA_RESIZE_TRACE").is_some()) + && TRACE_SAMPLES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 256 + { + crate::gpui_shell::try_write_stderr(format_args!( + "[nebula:resize-trace] prepaint pane={} origin=({:.2},{:.2}) content={:.2}x{:.2} cell={:.2}x{:.2} scale={:.2} viewport={}x{}", + view.pane_id, + bounds.origin.x.as_f32(), + bounds.origin.y.as_f32(), + bounds.size.width.as_f32(), + bounds.size.height.as_f32(), + cell_width.as_f32(), + line_height.as_f32(), + scale, + view.grid_cols(), + view.grid_rows(), + )); + } TermLayout { cell_width, line_height, diff --git a/scripts/conformance/cases/resize.py b/scripts/conformance/cases/resize.py index 4fa0c1f..e4ec1d1 100644 --- a/scripts/conformance/cases/resize.py +++ b/scripts/conformance/cases/resize.py @@ -1,8 +1,65 @@ from __future__ import annotations +import json +import os + from conformance.harness import ConformanceContext, require +def _linux_terminal_size(pid: int) -> dict[str, int]: + import fcntl + import struct + import termios + + # Read the shell's actual PTY geometry without relying on COLUMNS or the + # command-substitution stdout used by tput. O_NOCTTY preserves ownership. + fd = os.open(f"/proc/{pid}/fd/0", os.O_RDONLY | os.O_NOCTTY | os.O_CLOEXEC) + try: + packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)) + rows, columns, _, _ = struct.unpack("HHHH", packed) + return {"rows": rows, "columns": columns} + finally: + os.close(fd) + + +def _write_failure_diagnostics( + ctx: ConformanceContext, + source: int, + sibling: int, + measurements: dict[str, int], + error: Exception, +) -> None: + details: dict[str, object] = { + "error": str(error), + "shell": ctx.shell, + "measurements": measurements, + "panes": {}, + } + try: + details["snapshot"] = ctx.api("runtime.snapshot", timeout=2.0) + except Exception as diagnostic_error: + details["snapshot_error"] = str(diagnostic_error) + panes: dict[str, object] = {} + for pane_id in (source, sibling): + params = {"window_id": ctx.window_id, "pane_id": pane_id} + pane: dict[str, object] = {} + try: + pane["read"] = ctx.api("pane.read", {**params, "lines": 40}, timeout=2.0) + except Exception as diagnostic_error: + pane["read_error"] = str(diagnostic_error) + if ctx.platform_family == "linux": + try: + processes = ctx.api("pane.procs", params, timeout=2.0) + pane["kernel_terminal_size"] = _linux_terminal_size(int(processes["root_pid"])) + except Exception as diagnostic_error: + pane["kernel_terminal_size_error"] = str(diagnostic_error) + panes[str(pane_id)] = pane + details["panes"] = panes + (ctx.artifact_dir / "resize-failure.json").write_text( + json.dumps(details, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + def run(ctx: ConformanceContext) -> dict[str, object]: source = ctx.pane_id split = ctx.api( @@ -14,16 +71,21 @@ def run(ctx: ConformanceContext) -> dict[str, object]: }, ) sibling = int(split["action"]["pane_id"]) + measurements: dict[str, int] = {} try: source_before = ctx.measure_columns(source) + measurements["source_columns_before"] = source_before sibling_before = ctx.measure_columns(sibling) + measurements["sibling_columns_before"] = sibling_before resized = ctx.api( "pane.resize", {"window_id": ctx.window_id, "pane_id": source, "ratio": 0.70}, ) ratio = float(resized["action"]["ratio"]) source_after = ctx.measure_columns(source) + measurements["source_columns_after"] = source_after sibling_after = ctx.measure_columns(sibling) + measurements["sibling_columns_after"] = sibling_after require(abs(ratio - 0.70) < 0.001, f"runtime reported wrong split ratio: {ratio}") require( source_after > source_before, @@ -45,6 +107,14 @@ def run(ctx: ConformanceContext) -> dict[str, object]: "sibling_columns_before": sibling_before, "sibling_columns_after": sibling_after, } + except Exception as error: + # Capture only after a strict assertion fails. Extra observations must + # not turn into an implicit wait that makes a resize race disappear. + try: + _write_failure_diagnostics(ctx, source, sibling, measurements, error) + except Exception as diagnostic_error: + print(f"[resize] could not retain diagnostics: {diagnostic_error}", flush=True) + raise finally: ctx.best_effort_api("pane.close", {"window_id": ctx.window_id, "pane_id": sibling}) ctx.best_effort_api("window.focus", {"window_id": ctx.window_id, "pane_id": source}) diff --git a/scripts/conformance/tests/test_resize.py b/scripts/conformance/tests/test_resize.py new file mode 100644 index 0000000..c066cf6 --- /dev/null +++ b/scripts/conformance/tests/test_resize.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] +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 + + +class ResizeContext: + window_id = 1 + pane_id = 10 + shell = "bash" + platform_family = "macos" + + def __init__(self, artifact_dir: Path, columns: list[int]) -> None: + self.artifact_dir = artifact_dir + self.columns = iter(columns) + self.cleanup: list[str] = [] + + def api(self, method: str, params=None, **kwargs): + if method == "pane.split": + return {"action": {"pane_id": 11}} + if method == "pane.resize": + return {"action": {"ratio": params["ratio"]}} + if method == "runtime.snapshot": + return {"windows": [{"id": self.window_id}]} + if method == "pane.read": + return {"text": "NEBULA_COLS_3_116"} + raise AssertionError(method) + + def measure_columns(self, pane_id: int) -> int: + return next(self.columns) + + def best_effort_api(self, method: str, params) -> None: + self.cleanup.append(method) + + +class ResizeDiagnosticsTests(unittest.TestCase): + 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]) + with patch.object(resize, "_write_failure_diagnostics") as diagnose: + result = resize.run(ctx) + self.assertTrue(result["pty_columns_changed"]) + diagnose.assert_not_called() + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_unchanged_spawn_width_still_fails_and_preserves_all_measurements(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [116, 116, 116, 116]) + with self.assertRaisesRegex(ConformanceError, "70% pane did not grow: 116 -> 116"): + resize.run(ctx) + details = json.loads((Path(directory) / "resize-failure.json").read_text()) + self.assertEqual(details["measurements"], { + "source_columns_before": 116, + "sibling_columns_before": 116, + "source_columns_after": 116, + "sibling_columns_after": 116, + }) + self.assertEqual(set(details["panes"]), {"10", "11"}) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_sibling_and_total_assertions_remain_strict(self) -> None: + for columns, message in [ + ([57, 56, 80, 56], "30% pane did not shrink"), + ([57, 56, 85, 15], "PTY column total changed"), + ]: + with self.subTest(columns=columns), tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), columns) + with self.assertRaisesRegex(ConformanceError, message): + resize.run(ctx) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_diagnostic_failure_cannot_replace_the_resize_failure(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [116] * 4) + with patch.object(resize, "_write_failure_diagnostics", side_effect=OSError("read-only")), \ + patch("builtins.print"): + with self.assertRaisesRegex(ConformanceError, "70% pane did not grow"): + resize.run(ctx) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + @unittest.skipUnless(sys.platform == "linux", "reads the Linux process PTY") + def test_kernel_probe_follows_pty_geometry_even_with_stale_columns_environment(self) -> None: + import fcntl + import struct + import termios + + master, slave = os.openpty() + child = None + try: + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + stdin=slave, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env={**os.environ, "COLUMNS": "116"}, + ) + for columns in (57, 82): + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, columns, 0, 0)) + self.assertEqual( + resize._linux_terminal_size(child.pid), + {"rows": 30, "columns": columns}, + ) + finally: + if child is not None: + child.terminate() + child.wait(timeout=5) + os.close(master) + os.close(slave) + + +if __name__ == "__main__": + unittest.main()