diff --git a/.github/workflows/windows-arm64-diagnostics.yml b/.github/workflows/windows-arm64-diagnostics.yml deleted file mode 100644 index 76ccecb..0000000 --- a/.github/workflows/windows-arm64-diagnostics.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Windows ARM64 conformance diagnostics - -on: - workflow_dispatch: - inputs: - package_run: - description: Release run whose stable-package-windows-arm64 artifact should be diagnosed - required: true - type: string - push: - branches: [release/1.9.0-final] - paths: - - .github/workflows/windows-arm64-diagnostics.yml - - scripts/conformance/conpty_smoke.py - - scripts/conformance/explicit_command_boot.py - - scripts/conformance/windows_arm64_module_path.ps1 - -permissions: - contents: read - actions: read - -env: - # Dispatch is only available once the workflow exists on the default branch; - # branch pushes diagnose the latest ARM64 package produced by the release run. - PACKAGE_RUN: ${{ inputs.package_run || '35687864774' }} - -jobs: - diagnose: - runs-on: windows-11-arm - timeout-minutes: 20 - env: - PYTHONUTF8: "1" - NEBULA_RESIZE_TRACE: "1" - NEBULA_BOOT_TRACE: "1" - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - uses: actions/download-artifact@v4 - with: - name: stable-package-windows-arm64 - run-id: ${{ env.PACKAGE_RUN }} - github-token: ${{ github.token }} - path: diagnostic-package - - name: Extract the portable package - shell: pwsh - run: | - $zip = Get-ChildItem diagnostic-package -Filter *.zip | Select-Object -First 1 - Expand-Archive -LiteralPath $zip.FullName -DestinationPath diagnostic-runtime -Force - Get-ChildItem -Recurse diagnostic-runtime | Select-Object FullName, Length - - name: Describe the host - shell: pwsh - run: | - "PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE" - [System.Runtime.InteropServices.RuntimeInformation]::OSDescription - [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture - whoami /groups | Select-String -Pattern 'Mandatory|Administrators' - foreach ($name in 'pwsh', 'powershell', 'cmd', 'python') { - $command = Get-Command $name -ErrorAction SilentlyContinue - if ($null -eq $command) { "${name}: not found"; continue } - $bytes = [IO.File]::ReadAllBytes($command.Source) - $offset = [BitConverter]::ToInt32($bytes, 0x3C) - $machine = [BitConverter]::ToUInt16($bytes, $offset + 4) - "{0}: {1} machine={2:x4}" -f $name, $command.Source, $machine - } - python -c "import platform, sys; print(sys.version, platform.machine())" - - name: Explicit -e commands through the application - if: always() - shell: pwsh - run: python scripts/conformance/windows_standard_user.py scripts/conformance/explicit_command_boot.py diagnostic-runtime/pebrel.exe diagnostic-output/explicit-commands.json - - name: Apply the ARM64 image module-path workaround - if: always() - shell: pwsh - run: ./scripts/conformance/windows_arm64_module_path.ps1 - - name: Explicit -e commands after the workaround - if: always() - shell: pwsh - run: python scripts/conformance/windows_standard_user.py scripts/conformance/explicit_command_boot.py diagnostic-runtime/pebrel.exe diagnostic-output/explicit-commands-fixed.json - - name: Conformance after the workaround - if: always() - shell: pwsh - run: python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime/pebrel.exe --platform windows-aarch64 --output diagnostic-output/fixed/windows-arm64-report.json - - uses: actions/upload-artifact@v4 - if: always() - with: - name: windows-arm64-conformance-diagnostics - path: diagnostic-output - if-no-files-found: warn - retention-days: 7 diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 571289d..818accb 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -841,7 +841,6 @@ pub struct NebulaWorkspace { /// 系统关闭按钮可能连续送来多次 should-close;确认框在场时只保留一份。 window_close_confirm_open: bool, window_close_pending: bool, - recovery_boot_attempts: u32, /// `keep_session` 关窗后 HWND 已隐藏、PTY 仍在;托盘 / mux ATTACH 用来捞回。 window_hidden: bool, /// 开窗时记下,mux `tab.new` 需要从 pump 拿到 `&mut Window`。 @@ -1073,7 +1072,6 @@ impl NebulaWorkspace { spinner_visible: std::cell::Cell::new(false), window_close_confirm_open: false, window_close_pending: false, - recovery_boot_attempts: 0, window_hidden: false, window_handle: window.window_handle(), runtime_window_id, diff --git a/nebula_app/src/gpui_shell/workspace/session_recovery.rs b/nebula_app/src/gpui_shell/workspace/session_recovery.rs index 5edfc07..a529c7c 100644 --- a/nebula_app/src/gpui_shell/workspace/session_recovery.rs +++ b/nebula_app/src/gpui_shell/workspace/session_recovery.rs @@ -10,7 +10,6 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) -> bool { - self.recovery_boot_attempts = session.boot_attempts.saturating_add(1); let mut restored = false; for tab in &session.tabs { restored |= self.restore_tab(tab, resume_ai, window, cx); @@ -50,7 +49,6 @@ impl NebulaWorkspace { } let crashed = crate::session::was_crash(&session); crate::session::mark_boot_attempt(&mut session); - self.recovery_boot_attempts = session.boot_attempts; let mut restored = 0usize; for tab in &session.tabs { if self.restore_tab(tab, resume_ai, window, cx) { @@ -154,18 +152,23 @@ impl NebulaWorkspace { /// 当前工作区 → 共享 v4 快照。设置/文档/图片 tab 不进会话(旧壳同 /// 合同);AI 会话身份优先取 hook 直报的精确 id,退而取可解析的前台 /// 程序名(claude 无 id 恢复成 `--continue`,安全判定在 schema 层)。 + /// + /// 快照一律带 `boot_attempts = 0`:断路器只回答「这次启动活到了第一次 + /// 自动保存没有」(session.rs 模块合同)。agent 恢复目标尚未被 hook / + /// 探针确认是另一回事——codex 停在 hooks 信任、目录选择或「已在别处 + /// 打开」的提示上可以是几小时,正常退出照样算一次失败的话,三次就把 + /// 整个工作区(含所有普通 tab)隔离掉了。未确认的目标本身仍随 + /// `session_agent()` 落盘,下次启动照旧接续。 pub(crate) fn snapshot_session(&self, cx: &App) -> crate::session::Session { use crate::session::{AgentSession, LaunchSession, Session, TabSession}; let mut tabs = Vec::new(); - let mut recovery_pending = false; let mut active_out = 0usize; for (ix, tab) in self.tabs.iter().enumerate() { let WorkspaceTab::Terminal { panes, tree, focused, .. } = tab else { continue }; if ix == self.active { active_out = tabs.len(); } - recovery_pending |= panes.iter().any(|pane| pane.view.read(cx).recovery_pending()); let leaf_data = |id: u64| -> (String, Option, Option) { let Some(pane) = panes.iter().find(|pane| pane.id == id) else { return (String::new(), None, None); @@ -202,10 +205,6 @@ impl NebulaWorkspace { active_pane, }); } - let mut session = Session::new(active_out, tabs); - if recovery_pending { - session.boot_attempts = self.recovery_boot_attempts; - } - session + Session::new(active_out, tabs) } } diff --git a/nebula_app/src/gpui_shell/workspace/tests.rs b/nebula_app/src/gpui_shell/workspace/tests.rs index b6103f5..a9da3d8 100644 --- a/nebula_app/src/gpui_shell/workspace/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/tests.rs @@ -805,3 +805,91 @@ fn sidebar_resize_hot_zone_follows_the_theme_boundary() { // 热区宽度足以盖住线:线心两侧各 3px。 assert!(SIDEBAR_RESIZE_HANDLE_WIDTH * 0.5 > nord.divider); } + +/// 回归:冷恢复的 codex 目标停在 hooks 信任 / 目录选择 / 「已在别处打开」 +/// 提示上时,hook 报不了、探针拿不到 rollout,pane 一直是「未确认」。此前 +/// 快照把 `boot_attempts` 钉在启动时的 +1 值,1 Hz 自动保存永不归零—— +/// 三次正常退出后整个工作区被挪去 `session.crashed.json`。断路器只回答 +/// 「这次启动活到了第一次自动保存没有」,未确认的目标只随快照落盘、不计数。 +#[cfg(feature = "gpui-test-support")] +mod boot_breaker_tests { + use super::*; + use crate::session::{AgentSession, LaunchSession, LayoutSession, TabSession}; + use gpui::TestAppContext; + + #[gpui::test] + fn unconfirmed_agent_target_does_not_pin_the_boot_breaker(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::gpui_shell::math_view::register(cx); + crate::gpui_shell::file_editor::init(cx); + super::super::init(cx); + cx.set_reduce_motion(true); + windowing::initialize(cx, crate::runtime_api::RuntimeHub::new()); + }); + let mut workspace = None; + let (_, mut window) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| { + NebulaWorkspace::new( + window, + None, + None, + 1, + crate::runtime_api::RuntimeHub::new(), + windowing::WorkspaceStartup::Empty, + windowing::WindowRole::Regular, + cx, + ) + }); + workspace = Some(view.clone()); + Root::new(view, window, cx) + }); + let workspace = workspace.unwrap(); + + let saved = AgentSession { + source: "codex".into(), + session_id: Some("saved-42".into()), + session_file: None, + }; + let launch = LaunchSession::Shell { + name: "missing".into(), + program: "pebrel-test-missing-shell-executable".into(), + args: vec![], + }; + let tab = TabSession { + cwd: String::new(), + custom_name: None, + color: None, + launch: Some(launch.clone()), + layout: Some(LayoutSession::Pane { + cwd: String::new(), + agent: Some(saved.clone()), + launch: Some(launch), + }), + active_pane: 0, + }; + // 第三次启动:磁盘上的计数已被 `mark_boot_attempt` 抬到 2。 + let mut session = crate::session::Session::new(0, vec![tab]); + session.boot_attempts = 2; + window.update(|window, cx| { + workspace.update(cx, |workspace, cx| { + assert!(workspace.restore_update_session(&session, true, window, cx)); + let pane = workspace.tabs.last().and_then(|tab| tab.focused_view()).unwrap(); + assert!(pane.read(cx).recovery_pending(), "codex 目标尚未被 hook / 探针确认"); + + let snapshot = workspace.snapshot_session(cx); + assert_eq!(snapshot.boot_attempts, 0, "未确认的恢复目标不得钉住断路器计数"); + assert!( + crate::session::should_restore(&snapshot), + "首次自动保存即让下次启动照常恢复" + ); + let Some(LayoutSession::Pane { agent, .. }) = + snapshot.tabs.last().and_then(|tab| tab.layout.as_ref()) + else { + panic!("restored tab must snapshot as a single pane"); + }; + assert_eq!(agent.as_ref(), Some(&saved), "恢复目标本身仍随快照落盘,下次照旧接续"); + }) + }); + } +} diff --git a/scripts/conformance/conpty_smoke.py b/scripts/conformance/conpty_smoke.py deleted file mode 100644 index 6dc0b98..0000000 --- a/scripts/conformance/conpty_smoke.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Spawn a shell through ConPTY without Pebrel and report its first output. - -Separates runtime failures (conpty.dll, OpenConsole.exe, the shell itself) -from application failures when a platform renders no initial shell output. -Both the side-loaded pair and the in-box kernel32 ConPTY are exercised. -""" - -from __future__ import annotations - -import argparse -import ctypes -from ctypes import wintypes as wt -import json -import os -import shutil -import sys -import threading -import time - -PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 -EXTENDED_STARTUPINFO_PRESENT = 0x00080000 -CREATE_UNICODE_ENVIRONMENT = 0x00000400 -STARTF_USESTDHANDLES = 0x00000100 -PSEUDOCONSOLE_WIN32_INPUT_MODE = 0x4 - - -class Coord(ctypes.Structure): - _fields_ = [("X", ctypes.c_short), ("Y", ctypes.c_short)] - - -class StartupInfo(ctypes.Structure): - _fields_ = [ - ("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR), - ("lpTitle", wt.LPWSTR), ("dwX", wt.DWORD), ("dwY", wt.DWORD), - ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD), ("dwXCountChars", wt.DWORD), - ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD), - ("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD), - ("lpReserved2", ctypes.POINTER(wt.BYTE)), ("hStdInput", wt.HANDLE), - ("hStdOutput", wt.HANDLE), ("hStdError", wt.HANDLE), - ] - - -class StartupInfoEx(ctypes.Structure): - _fields_ = [("StartupInfo", StartupInfo), ("lpAttributeList", wt.LPVOID)] - - -class ProcessInfo(ctypes.Structure): - _fields_ = [("hProcess", wt.HANDLE), ("hThread", wt.HANDLE), - ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD)] - - -kernel = ctypes.WinDLL("kernel32", use_last_error=True) -kernel.CreatePipe.argtypes = [ctypes.POINTER(wt.HANDLE), ctypes.POINTER(wt.HANDLE), wt.LPVOID, wt.DWORD] -kernel.CreatePipe.restype = wt.BOOL -kernel.ReadFile.argtypes = [wt.HANDLE, wt.LPVOID, wt.DWORD, ctypes.POINTER(wt.DWORD), wt.LPVOID] -kernel.ReadFile.restype = wt.BOOL -kernel.WriteFile.argtypes = [wt.HANDLE, wt.LPCVOID, wt.DWORD, ctypes.POINTER(wt.DWORD), wt.LPVOID] -kernel.WriteFile.restype = wt.BOOL -kernel.CloseHandle.argtypes = [wt.HANDLE] -kernel.CloseHandle.restype = wt.BOOL -kernel.InitializeProcThreadAttributeList.argtypes = [wt.LPVOID, wt.DWORD, wt.DWORD, ctypes.POINTER(ctypes.c_size_t)] -kernel.InitializeProcThreadAttributeList.restype = wt.BOOL -kernel.UpdateProcThreadAttribute.argtypes = [wt.LPVOID, wt.DWORD, ctypes.c_size_t, wt.LPVOID, ctypes.c_size_t, wt.LPVOID, wt.LPVOID] -kernel.UpdateProcThreadAttribute.restype = wt.BOOL -kernel.DeleteProcThreadAttributeList.argtypes = [wt.LPVOID] -kernel.CreateProcessW.argtypes = [wt.LPCWSTR, wt.LPWSTR, wt.LPVOID, wt.LPVOID, wt.BOOL, wt.DWORD, wt.LPVOID, wt.LPCWSTR, wt.LPVOID, ctypes.POINTER(ProcessInfo)] -kernel.CreateProcessW.restype = wt.BOOL -kernel.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD] -kernel.WaitForSingleObject.restype = wt.DWORD -kernel.GetExitCodeProcess.argtypes = [wt.HANDLE, ctypes.POINTER(wt.DWORD)] -kernel.GetExitCodeProcess.restype = wt.BOOL -kernel.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT] -kernel.TerminateProcess.restype = wt.BOOL -kernel.FreeConsole.argtypes = [] -kernel.FreeConsole.restype = wt.BOOL -kernel.AttachConsole.argtypes = [wt.DWORD] -kernel.AttachConsole.restype = wt.BOOL -kernel.CreateFileW.argtypes = [wt.LPCWSTR, wt.DWORD, wt.DWORD, wt.LPVOID, wt.DWORD, wt.DWORD, wt.HANDLE] -kernel.CreateFileW.restype = wt.HANDLE -kernel.GetConsoleScreenBufferInfo.argtypes = [wt.HANDLE, wt.LPVOID] -kernel.GetConsoleScreenBufferInfo.restype = wt.BOOL -INVALID_HANDLE_VALUE = wt.HANDLE(-1).value - - -def cursor_probe(pid: int) -> dict[str, object]: - """The same conhost cursor probe Pebrel runs after every resize.""" - kernel.FreeConsole() - if not kernel.AttachConsole(pid): - return {"attach_error": ctypes.get_last_error()} - handle = kernel.CreateFileW("CONOUT$", 0xC0000000, 0x3, None, 3, 0, None) - if handle == INVALID_HANDLE_VALUE: - error = ctypes.get_last_error() - kernel.FreeConsole() - return {"conout_error": error} - info = ctypes.create_string_buffer(22) - ok = kernel.GetConsoleScreenBufferInfo(handle, info) - error = ctypes.get_last_error() - kernel.CloseHandle(handle) - kernel.FreeConsole() - if not ok: - return {"info_error": error} - # CONSOLE_SCREEN_BUFFER_INFO: dwSize, dwCursorPosition, wAttributes, srWindow. - cursor_y = int.from_bytes(info.raw[6:8], "little", signed=True) - top = int.from_bytes(info.raw[12:14], "little", signed=True) - bottom = int.from_bytes(info.raw[16:18], "little", signed=True) - return {"row": cursor_y - top, "rows": bottom - top + 1} - -CreateFn = ctypes.WINFUNCTYPE(ctypes.c_long, Coord, wt.HANDLE, wt.HANDLE, wt.DWORD, ctypes.POINTER(wt.LPVOID)) -ResizeFn = ctypes.WINFUNCTYPE(ctypes.c_long, wt.LPVOID, Coord) -CloseFn = ctypes.WINFUNCTYPE(None, wt.LPVOID) - - -def api(library: str | None) -> tuple[CreateFn, ResizeFn, CloseFn]: - module = ctypes.WinDLL(library, use_last_error=True) if library else kernel - create = CreateFn(("CreatePseudoConsole", module)) - resize = ResizeFn(("ResizePseudoConsole", module)) - close = CloseFn(("ClosePseudoConsole", module)) - return create, resize, close - - -def run_case( - library: str | None, - command: str, - flags: int, - wait_seconds: float, - done_marker: bytes = b"CONPTY_SMOKE_OK", - steps: tuple[str, ...] = (), - env: dict[str, str] | None = None, - cwd: str | None = None, -) -> dict[str, object]: - """Interactive shells never exit; the marker ends the wait once it renders. - - `env` replaces the inherited block (CREATE_UNICODE_ENVIRONMENT) and `cwd` - the inherited directory, so Pebrel's own child environment can be replayed. - - `steps` replays Pebrel's post-spawn sequence 30ms after the first output: - `resize` calls ResizePseudoConsole, `probe` attaches to the client console - and reads its cursor, mirroring the align-sync and align stages. - """ - result: dict[str, object] = { - "library": library or "kernel32", "command": command, "flags": flags, "steps": list(steps), - } - try: - create, resize, close = api(library) - except OSError as error: - result["error"] = f"load failed: {error}" - return result - conout_read, conout_write = wt.HANDLE(), wt.HANDLE() - conin_read, conin_write = wt.HANDLE(), wt.HANDLE() - if not kernel.CreatePipe(ctypes.byref(conout_read), ctypes.byref(conout_write), None, 0) or \ - not kernel.CreatePipe(ctypes.byref(conin_read), ctypes.byref(conin_write), None, 0): - result["error"] = f"CreatePipe failed: {ctypes.get_last_error()}" - return result - hpc = wt.LPVOID() - started = time.monotonic() - hresult = create(Coord(80, 25), conin_read, conout_write, flags, ctypes.byref(hpc)) - result["create_hresult"] = f"{hresult & 0xFFFFFFFF:#010x}" - result["create_ms"] = round((time.monotonic() - started) * 1000) - if hresult != 0: - return result - # The side-loaded host answers the same primed DA1 reply Pebrel writes. - written = wt.DWORD() - kernel.WriteFile(conin_write, b"\x1b[?61c", 6, ctypes.byref(written), None) - - size = ctypes.c_size_t(0) - kernel.InitializeProcThreadAttributeList(None, 1, 0, ctypes.byref(size)) - attributes = ctypes.create_string_buffer(size.value) - if not kernel.InitializeProcThreadAttributeList(attributes, 1, 0, ctypes.byref(size)): - result["error"] = f"InitializeProcThreadAttributeList failed: {ctypes.get_last_error()}" - return result - if not kernel.UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, - hpc, ctypes.sizeof(wt.LPVOID), None, None): - result["error"] = f"UpdateProcThreadAttribute failed: {ctypes.get_last_error()}" - return result - startup = StartupInfoEx() - startup.StartupInfo.cb = ctypes.sizeof(startup) - # Null standard handles with USESTDHANDLES: the client binds to the - # pseudoconsole instead of inheriting this process's console or pipes. - startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES - startup.lpAttributeList = ctypes.cast(attributes, wt.LPVOID) - process = ProcessInfo() - cmdline = ctypes.create_unicode_buffer(command) - creation_flags = EXTENDED_STARTUPINFO_PRESENT - env_block = None - if env is not None: - creation_flags |= CREATE_UNICODE_ENVIRONMENT - block = "".join(f"{name}={value}\0" for name, value in sorted(env.items(), key=lambda item: item[0].upper())) - env_block = ctypes.create_unicode_buffer(block + "\0") - if not kernel.CreateProcessW(None, cmdline, None, None, False, creation_flags, - env_block, cwd, ctypes.byref(startup), ctypes.byref(process)): - result["error"] = f"CreateProcessW failed: {ctypes.get_last_error()}" - return result - result["pid"] = process.dwProcessId - - chunks: list[bytes] = [] - - def reader() -> None: - buffer = ctypes.create_string_buffer(4096) - read = wt.DWORD() - while kernel.ReadFile(conout_read, buffer, 4096, ctypes.byref(read), None) and read.value: - chunks.append(buffer.raw[: read.value]) - - thread = threading.Thread(target=reader, daemon=True) - thread.start() - deadline = time.monotonic() + wait_seconds - first_output_ms: int | None = None - marker_ms: int | None = None - steps_at: float | None = None - step_log: list[dict[str, object]] = [] - while time.monotonic() < deadline: - if chunks and first_output_ms is None: - first_output_ms = round((time.monotonic() - started) * 1000) - steps_at = time.monotonic() + 0.03 - if steps and steps_at is not None and time.monotonic() >= steps_at: - for step in steps: - entry: dict[str, object] = {"step": step, "at_ms": round((time.monotonic() - started) * 1000)} - if step == "resize": - entry["hresult"] = f"{resize(hpc, Coord(82, 25)) & 0xFFFFFFFF:#010x}" - elif step == "probe": - entry.update(cursor_probe(process.dwProcessId)) - entry["bytes_after"] = sum(len(chunk) for chunk in chunks) - step_log.append(entry) - steps = () - if marker_ms is None and done_marker in b"".join(chunks): - marker_ms = round((time.monotonic() - started) * 1000) - break - if kernel.WaitForSingleObject(process.hProcess, 100) == 0: - break - result["step_log"] = step_log - result["marker_ms"] = marker_ms - code = wt.DWORD() - kernel.GetExitCodeProcess(process.hProcess, ctypes.byref(code)) - if code.value == 259: # STILL_ACTIVE - kernel.TerminateProcess(process.hProcess, 1) - result["exit"] = "terminated after timeout" - else: - result["exit"] = code.value - # The in-box host flushes trailing output while ClosePseudoConsole drains - # the pipe; the reader thread keeps consuming during that call. - if conout_write is not None: - kernel.CloseHandle(conout_write) - close(hpc) - thread.join(2.0) - output = b"".join(chunks) - if chunks and first_output_ms is None: - first_output_ms = round((time.monotonic() - started) * 1000) - result["first_output_ms"] = first_output_ms - result["output_bytes"] = len(output) - result["output_head"] = output[:2000].decode("utf-8", "replace") - kernel.CloseHandle(process.hThread) - kernel.CloseHandle(process.hProcess) - kernel.DeleteProcThreadAttributeList(attributes) - for handle in (conin_write, conin_read, conout_read): - if handle is not None: - kernel.CloseHandle(handle) - return result - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--runtime", help="directory holding conpty.dll and OpenConsole.exe") - parser.add_argument("--wait", type=float, default=15.0) - parser.add_argument("--output") - parser.add_argument( - "--prompt-script", - help="also start Windows PowerShell interactively with this integration script, like Pebrel does", - ) - args = parser.parse_args() - # Terminal bytes include private-use glyphs that legacy code pages reject. - sys.stdout.reconfigure(encoding="utf-8", errors="replace") - - shells = [] - for name in ("pwsh", "powershell", "cmd"): - path = shutil.which(name) - if path: - shells.append((name, path)) - libraries: list[str | None] = [None] - if args.runtime: - libraries.insert(0, os.path.join(os.path.abspath(args.runtime), "conpty.dll")) - - report: dict[str, object] = { - "architecture": os.environ.get("PROCESSOR_ARCHITECTURE"), - "python": sys.version, - "shells": dict(shells), - "cases": [], - } - commands = { - "pwsh": '"{path}" -NoLogo -NoProfile -Command "Write-Output CONPTY_SMOKE_OK"', - "powershell": '"{path}" -NoLogo -NoProfile -Command "Write-Output CONPTY_SMOKE_OK"', - "cmd": '"{path}" /d /c "echo CONPTY_SMOKE_OK"', - } - for library in libraries: - for name, path in shells: - for flags in (PSEUDOCONSOLE_WIN32_INPUT_MODE, 0): - case = run_case(library, commands[name].format(path=path), flags, args.wait) - case["shell"] = name - print(json.dumps(case, ensure_ascii=False), flush=True) - report["cases"].append(case) - powershell = dict(shells).get("powershell") - if args.prompt_script and powershell: - # The prompt function emits OSC 133;A once PowerShell reaches its - # first interactive prompt; a bare interactive shell shows "PS ". - script = os.path.abspath(args.prompt_script) - interactive = { - "powershell-interactive": (f'"{powershell}" -NoLogo -NoExit', b"PS "), - "powershell-integration": ( - f'"{powershell}" -NoLogo -NoExit -ExecutionPolicy Bypass -Command ". \'{script}\'"', - b"133;A", - ), - } - variants: list[tuple[str, ...]] = [ - (), - ("resize", "probe", "probe"), - ("early_close",), - ("early_close", "resize", "probe", "probe"), - ] - for name, (command, marker) in interactive.items(): - for steps in variants: - case = run_case( - library, command, PSEUDOCONSOLE_WIN32_INPUT_MODE, args.wait, marker, steps, - ) - case["shell"] = name - print(json.dumps(case, ensure_ascii=False), flush=True) - report["cases"].append(case) - if args.output: - os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) - with open(args.output, "w", encoding="utf-8") as handle: - json.dump(report, handle, ensure_ascii=False, indent=2) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/conformance/explicit_command_boot.py b/scripts/conformance/explicit_command_boot.py deleted file mode 100644 index 1b19277..0000000 --- a/scripts/conformance/explicit_command_boot.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Boot Pebrel with explicit `-e` commands and dump what each pane renders. - -Separates "the shell hangs at startup" from "the PTY read path is dead": -cmd, PowerShell 5.1 and pwsh are launched through the same ConPTY and -environment path as the default tab, and every pane is read for 15s. - -The `cmd-env` case captures the exact environment block Pebrel hands its -children; PowerShell 5.1 is then replayed outside the application with that -block and/or that working directory, and the differing variables are bisected -when the block alone reproduces a silent start. -""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, os.fspath(Path(__file__).resolve().parent.parent)) -sys.path.insert(0, os.fspath(Path(__file__).resolve().parent)) -import conpty_smoke # noqa: E402 -from conformance.harness import RuntimeClient # noqa: E402 - -MARKER = "PEBREL_SMOKE_OK" -POWERSHELL_51 = ["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", f"Write-Output {MARKER}"] -REPLAY_WAIT = 10.0 - -app = Path(sys.argv[1]).resolve() -output = Path(sys.argv[2]) -runtime_library = app.parent / "runtime" / "conpty.dll" -report: dict[str, object] = {} -app_env: dict[str, str] | None = None -app_cwd: str | None = None - - -def boot(name: str, command: list[str], env_dump: Path | None = None) -> dict[str, object]: - global app_env - with tempfile.TemporaryDirectory(prefix="pebrel-cmd-", ignore_cleanup_errors=True) as root: - config = Path(root) / "config" - work = Path(root) / "work" - config.mkdir() - work.mkdir() - (config / "pebrel_settings.txt").write_text( - "keep_session=0\ntray=0\nrestore_session=0\nresume_ai=0\nfetch=0\n" - "auto_check_updates=0\nai_hooks=0\nwindowing_behavior=use_new\n", - encoding="utf-8", - ) - if env_dump is not None: - env_dump = Path(root) / env_dump - command = [*command, f"set > {env_dump}"] - env = dict(os.environ, PEBREL_CONFIG_DIR=os.fspath(config), NEBULA_CONFIG_DIR=os.fspath(config), NEBULA_BOOT_TRACE="1") - env.pop("PEBREL_RUNTIME_ENDPOINT", None) - log = open(Path(root) / "app.log", "wb") - process = subprocess.Popen( - [os.fspath(app), "--working-directory", os.fspath(work), "-e", *command], - cwd=work, env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, - ) - entry: dict[str, object] = {"command": command} - port = config / "runtime.port" - deadline = time.monotonic() + 20 - client = None - while time.monotonic() < deadline and client is None: - if port.is_file(): - try: - client = RuntimeClient.from_port_file(port, timeout=5.0) - client.request("runtime.describe", timeout=1.0) - except Exception: - client = None - time.sleep(0.1) - if client is None: - entry["error"] = "runtime did not come up" - else: - text = "" - started = time.monotonic() - first_text_ms = None - while time.monotonic() - started < 15: - try: - snapshot = client.request("runtime.snapshot", timeout=5.0)["result"] - window = snapshot["windows"][0] - pane = window["tabs"][0]["panes"][0] - read = client.request( - "pane.read", {"window_id": window["id"], "pane_id": pane["id"], "lines": 40}, timeout=5.0 - )["result"] - text = read.get("text", "") - entry["exited"] = read.get("exited") - entry["cwd"] = pane.get("cwd") - if text.strip() and first_text_ms is None: - first_text_ms = round((time.monotonic() - started) * 1000) - if MARKER in text: - break - if env_dump is not None and env_dump.is_file() and env_dump.stat().st_size > 0: - time.sleep(1.0) - break - except Exception as error: - entry["read_error"] = str(error) - time.sleep(0.25) - entry["first_text_ms"] = first_text_ms - entry["marker"] = MARKER in text - entry["text"] = text.strip()[:600] - if env_dump is not None and env_dump.is_file(): - parsed: dict[str, str] = {} - for line in env_dump.read_text(encoding="mbcs", errors="replace").splitlines(): - name, separator, value = line.partition("=") - if separator and name and not name.startswith("="): - parsed[name] = value - if parsed: - # Windows names are case-insensitive; os.environ upper-cases them. - app_env = {name.upper(): value for name, value in parsed.items()} - entry["app_env_count"] = len(parsed) - # The shell keeps the work directory as its cwd; take the whole tree down. - subprocess.run(["taskkill", "/T", "/F", "/PID", str(process.pid)], capture_output=True) - process.wait(10) - log.close() - entry["app_log"] = [ - line for line in (Path(root) / "app.log").read_text(encoding="utf-8", errors="replace").splitlines() - if "prepaint" not in line and "resize-trace" not in line - ][:40] - print(name, json.dumps({k: v for k, v in entry.items() if k != "app_log"}, ensure_ascii=False)[:400], flush=True) - return entry - - -def replay(name: str, env: dict[str, str] | None, cwd: str | None) -> bool: - """Run PowerShell 5.1 through the smoke path; True when the marker rendered.""" - library = os.fspath(runtime_library) if runtime_library.is_file() else None - command = subprocess.list2cmdline(POWERSHELL_51) - result = conpty_smoke.run_case(library, command, 0, REPLAY_WAIT, done_marker=MARKER.encode(), env=env, cwd=cwd) - passed = result.get("marker_ms") is not None - summary = {k: result.get(k) for k in ("error", "first_output_ms", "marker_ms", "output_bytes")} - print("replay", name, "PASS" if passed else "SILENT", json.dumps(summary), flush=True) - report.setdefault("replays", {})[name] = summary | {"passed": passed} # type: ignore[index] - return passed - - -def bisect(base: dict[str, str], target: dict[str, str]) -> None: - """Find which of the variables that differ between base and target break PS 5.1.""" - differing = sorted({*base, *target}, key=str.upper) - differing = [name for name in differing if base.get(name) != target.get(name)] - report["env_diff"] = {name: {"runner": base.get(name), "app": target.get(name)} for name in differing} - print(f"bisect over {len(differing)} differing variables", flush=True) - - def with_changes(names: list[str]) -> dict[str, str]: - env = dict(base) - for name in names: - if name in target: - env[name] = target[name] - else: - env.pop(name, None) - return env - - if replay("runner-env-plus-all-diffs", with_changes(differing), None): - report["bisect"] = "applying every differing variable to the runner environment still passes" - return - suspects = list(differing) - rounds = 0 - while len(suspects) > 1 and rounds < 12: - rounds += 1 - half = suspects[: len(suspects) // 2] - if not replay(f"round{rounds}-first-half({len(half)})", with_changes(half), None): - suspects = half - continue - rest = suspects[len(suspects) // 2 :] - if not replay(f"round{rounds}-second-half({len(rest)})", with_changes(rest), None): - suspects = rest - continue - report["bisect"] = f"no single half reproduces; remaining suspects: {suspects}" - return - report["bisect"] = {"culprits": suspects, "values": {name: target.get(name) for name in suspects}} - print("bisect result:", report["bisect"], flush=True) - - -def redacted(env: dict[str, str] | None) -> dict[str, str] | None: - if env is None: - return None - secret = ("TOKEN", "SECRET", "PASSWORD", "_KEY") - return {name: ("" if any(part in name.upper() for part in secret) else value) for name, value in env.items()} - - -# Same shape as the panes' working directory (under TEMP), but it outlives boot(). -app_cwd = os.fspath(Path(tempfile.mkdtemp(prefix="pebrel-cmd-")) / "work") -os.mkdir(app_cwd) -report["cmd-env"] = boot("cmd-env", ["cmd.exe", "/d", "/k"], env_dump=Path("app-env.txt")) -report["powershell"] = boot("powershell", POWERSHELL_51) -report["runner_env"] = redacted(dict(os.environ)) -report["app_env"] = redacted(app_env) -if app_env is not None: - control = replay("inherited-env-inherited-cwd", None, None) - env_only = replay("app-env-inherited-cwd", app_env, None) - cwd_only = replay("inherited-env-app-cwd", None, app_cwd) - replay("app-env-app-cwd", app_env, app_cwd) - if control and not env_only: - bisect(dict(os.environ), app_env) - if isinstance(report.get("bisect"), dict) and report["bisect"]["culprits"] == ["PSMODULEPATH"]: # type: ignore[index] - # Which entry of the registry-only module path stalls PowerShell 5.1, - # and is the stall tied to the interactive host (PSReadLine import)? - runner_entries = [entry for entry in os.environ.get("PSMODULEPATH", "").split(";") if entry] - app_entries = [entry for entry in app_env.get("PSMODULEPATH", "").split(";") if entry] - windows_default = [ - entry for entry in app_entries - if entry.lower().startswith((r"c:\program files\windowspowershell\modules", r"c:\windows\system32")) - ] - pwsh_entries = [entry for entry in runner_entries if entry not in app_entries] - variants: dict[str, tuple[list[str], list[str]]] = { - "app-entries": (app_entries, POWERSHELL_51), - "pwsh-dirs-then-app-entries": (pwsh_entries + app_entries, POWERSHELL_51), - "windows-default-only": (windows_default, POWERSHELL_51), - "windows-default-non-interactive": ( - windows_default, - ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", f"Write-Output {MARKER}"], - ), - "windows-default-import-psreadline-non-interactive": ( - windows_default, - ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", f"Import-Module PSReadLine; Write-Output {MARKER}"], - ), - "windows-default-list-psreadline-non-interactive": ( - windows_default, - ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", - f"Get-Module -ListAvailable PSReadLine | ForEach-Object {{ $_.Version.ToString() + ' ' + $_.Path }}; Write-Output {MARKER}"], - ), - } - variants["windows-default-import-utility-verbose"] = ( - windows_default, - ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", - f"Import-Module Microsoft.PowerShell.Utility -Verbose; Write-Output {MARKER}"], - ) - for index, entry in enumerate(app_entries): - variants[f"app-entries-without-{index}"] = ([e for e in app_entries if e != entry], POWERSHELL_51) - for name, (entries, command) in variants.items(): - env = dict(os.environ) - env["PSMODULEPATH"] = ";".join(entries) - library = os.fspath(runtime_library) if runtime_library.is_file() else None - result = conpty_smoke.run_case( - library, subprocess.list2cmdline(command), 0, REPLAY_WAIT, done_marker=MARKER.encode(), env=env, cwd=None - ) - summary = {k: result.get(k) for k in ("error", "first_output_ms", "marker_ms", "output_bytes")} - summary["output_head"] = str(result.get("output_head", ""))[:1500] - summary["entries"] = entries - print("psmodulepath", name, "PASS" if result.get("marker_ms") is not None else "SILENT", json.dumps(summary)[:400], flush=True) - report.setdefault("psmodulepath_variants", {})[name] = summary # type: ignore[index] - if isinstance(report.get("env_diff"), dict): - report["env_diff"] = { - name: {side: redacted({name: value})[name] if value is not None else None for side, value in sides.items()} # type: ignore[index] - for name, sides in report["env_diff"].items() # type: ignore[union-attr] - } -output.parent.mkdir(parents=True, exist_ok=True) -output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")