fix(gpui): stop unconfirmed agent restores from tripping the session breaker

snapshot_session() pinned boot_attempts at the launch value whenever any
pane still had an unconfirmed AI restore target, so the 1 Hz autosave never
reset the crash-loop counter. A Codex conversation waiting at its startup
prompts (hook review, directory choice, "open in another app") therefore
counted every normal exit as a failed start, and the third launch moved the
whole workspace to session.crashed.json.

The breaker only answers "did this launch reach its first autosave"; the
restore target is still persisted through session_agent() and resumed on the
next launch. Drop the coupling and the recovery_boot_attempts field, and add
a GPUI regression that restores an unconfirmed Codex target on the third
attempt and asserts the snapshot reports zero attempts.
This commit is contained in:
kuddev committed 2026-09-22 21:09:45 +08:00
1 parent f65ea59acf
commit 41c5477c57
6 files changed
+96 -683

No files matched your search

@@ -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
-2
View File
@@ -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,
@@ -10,7 +10,6 @@ impl NebulaWorkspace {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<AgentSession>, Option<LaunchSession>) {
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)
}
}
@@ -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), "恢复目标本身仍随快照落盘,下次照旧接续");
})
});
}
}
-333
View File
@@ -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())
@@ -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: ("<redacted>" 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")