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:
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
|
||||
@@ -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), "恢复目标本身仍随快照落盘,下次照旧接续");
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user