Skip to content

Commit 67fb403

Browse files
committed
B11: fix Windows terminal (ConPTY + QProcess fallback), add Windows test CI
1 parent 5010ea5 commit 67fb403

6 files changed

Lines changed: 215 additions & 116 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Windows tests
2+
3+
# Test-only: runs the full suite on a real Windows machine (real ConPTY via
4+
# pywinpty, real cmd.exe). It builds nothing and publishes nothing — no release,
5+
# no MSIX upload. Safe to run on every push.
6+
on:
7+
push:
8+
branches: [main]
9+
pull_request:
10+
branches: [main]
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
pytest-windows:
18+
runs-on: windows-latest
19+
timeout-minutes: 20
20+
env:
21+
QT_QPA_PLATFORM: offscreen
22+
MEDHA_REAL_CONPTY_TEST: "1"
23+
steps:
24+
- uses: actions/checkout@v4
25+
- uses: actions/setup-python@v5
26+
with:
27+
python-version: "3.12"
28+
- name: Install dependencies (includes pywinpty)
29+
run: python -m pip install -r requirements.txt pytest pyflakes
30+
- name: pywinpty importable as winpty
31+
run: python -c "from winpty import PtyProcess; print('winpty OK')"
32+
- name: pyflakes
33+
run: python -m pyflakes medha
34+
- name: pytest (offline)
35+
run: python -m pytest -q

‎docs/MEDHA-v2-PLAN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Release rule: fix and push all month, publish to the Store **once** at the end (
2525
| B8 | Medium | Closing a window with active disassembly can call a deleted QTextEdit. | `app.py` closeEvent | Stop child QProcesses before widget teardown. |
2626
| B9 | High | Closing an edited tab discarded unsaved work without confirmation. | `app.py` close_tab/closeEvent | Save/Discard/Cancel guard, including cancelled Save As. |
2727
| B10 | Medium | `ProjectExplorerWidget.__init__` doesn't call `load_workspace()`, so workspace projects aren't loaded and fallback logic creates a project with the workspace directory name. | `medha/project_manager.py` line 192 | Add `self.workspace_manager.load_workspace(workspace_path)` in `__init__`. |
28+
| B11 | **High** | Windows terminal never used ConPTY: code imported `pywinpty` (real module is `winpty`), wrong `spawn` args, blocking `read()` on the GUI thread, `send_control` doesn't exist; pywinpty not in build deps. Fallback never set `_shell_started` (commands stuck) and its Ctrl+C killed the shell. | `medha/terminal.py`, `requirements.txt`, spec | Fixed: `winpty` import, reader thread, `sendintr()`, fallback sets started + Ctrl+C terminates only shell children (psutil); `windows-tests.yml` runs the real-ConPTY test on windows-latest. |
2829
More bugs come from Track B (section 3) — append them to this table with the same columns.
2930

3031
## 2. Track A — New UI like VS Code / Antigravity (ChatGPT)

‎medha/terminal.py‎

Lines changed: 79 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,18 @@
1919
(runner.write_stdin) else shell stdin → shell/program output → append_output()
2020
"""
2121

22+
import logging
2223
import os
2324
import signal
2425

25-
from PySide6.QtCore import QObject, QSocketNotifier, QProcess, Signal, QTimer
26+
from PySide6.QtCore import QObject, QSocketNotifier, QProcess, Signal
2627
from PySide6.QtGui import QColor, QFontDatabase, QTextCursor
2728
from PySide6.QtWidgets import QLineEdit, QTextEdit, QVBoxLayout, QWidget
2829

2930
from medha import platform_info as P
3031

32+
logger = logging.getLogger(__name__)
33+
3134

3235
class _PtyProcess(QObject):
3336
"""
@@ -207,63 +210,82 @@ class _WindowsConPtyProcess(_PtyProcess):
207210
If pywinpty is not importable, we fall back to _QProcessFallback.
208211
"""
209212

213+
# Chunks read on the background reader thread are handed to the Qt (GUI)
214+
# thread through these signals — Qt queues them automatically because the
215+
# receiver lives in the GUI thread. WHY: winpty's read() BLOCKS until the
216+
# shell prints something, so it must never run on the GUI thread.
217+
_chunk = Signal(bytes)
218+
_eof = Signal()
219+
210220
def __init__(self, workspace_path, parent=None):
211221
super().__init__(workspace_path, parent)
212222
self._pty = None
213223
self._process = None
214-
self._notifier = None
224+
self._reader = None
225+
self._chunk.connect(self._on_chunk) # reader thread → GUI thread (queued)
226+
self._eof.connect(self._on_eof) # shell exited → finished signal
215227

216228
def start(self):
217-
import pywinpty # noqa: F401
229+
# The pip package is "pywinpty" but the importable module is "winpty".
230+
from winpty import PtyProcess
231+
import threading
218232

219-
# Create a ConPTY with default size (80x24)
220-
self._pty = pywinpty.PtyProcess.spawn(
233+
# Real ConPTY pseudo-console, 24 rows x 80 cols (winpty takes (rows, cols)).
234+
self._pty = PtyProcess.spawn(
221235
[os.environ.get("COMSPEC", r"C:\Windows\System32\cmd.exe"), "/Q"],
222236
cwd=self.workspace_path,
223-
cols=80,
224-
rows=24,
237+
dimensions=(24, 80),
225238
)
226239
self._process = self._pty
227240

228-
# Set up a timer to poll for output (pywinpty doesn't integrate with Qt's event loop directly)
229-
# We use a QTimer with a short interval to check for data.
230-
self._timer = QTimer(self)
231-
self._timer.timeout.connect(self._poll_output)
232-
self._timer.start(10) # 10 ms polling interval
241+
# Daemon thread: blocks on read(), forwards every chunk to the GUI thread.
242+
self._reader = threading.Thread(target=self._read_loop, name="medha-conpty-reader", daemon=True)
243+
self._reader.start()
233244

234245
self._shell_started = True
235246
self.ready_read.emit("[MEDHĀ Terminal] cmd.exe started\n".encode("utf-8"))
236247

237-
def _poll_output(self):
238-
if self._pty and self._pty.isalive():
248+
def _read_loop(self):
249+
# Runs on the reader thread. read() returns str; EOFError means the shell closed.
250+
pty = self._pty
251+
while pty is not None:
239252
try:
240-
data = self._pty.read(4096)
241-
if data:
242-
self.ready_read.emit(data.encode("utf-8", errors="replace"))
253+
data = pty.read(4096)
254+
except EOFError:
255+
break
243256
except Exception:
244-
pass
245-
else:
246-
self._timer.stop()
247-
self.finished.emit(0)
257+
logger.exception("ConPTY read failed")
258+
break
259+
if data:
260+
self._chunk.emit(data.encode("utf-8", errors="replace"))
261+
self._eof.emit()
262+
263+
def _on_chunk(self, data: bytes):
264+
# GUI thread: pass the shell output on to the terminal widget.
265+
self.ready_read.emit(data)
266+
267+
def _on_eof(self):
268+
# GUI thread: the shell process has ended.
269+
self._shell_started = False
270+
self.finished.emit(0)
248271

249272
def write(self, data: bytes):
250273
if self._pty and self._pty.isalive():
251274
try:
252275
self._pty.write(data.decode("utf-8", errors="replace"))
253276
except Exception:
254-
pass
277+
logger.exception("ConPTY write failed")
255278

256279
def send_signal(self, sig):
257-
# ConPTY doesn't have Unix signals; we send Ctrl+C as a keystroke
280+
# ConPTY has no Unix signals: Ctrl+C is sent as the ^C keystroke, which the
281+
# pseudo-console turns into CTRL_C_EVENT for the foreground program only.
258282
if sig == signal.SIGINT and self._pty and self._pty.isalive():
259283
try:
260-
self._pty.send_control('c')
284+
self._pty.sendintr()
261285
except Exception:
262-
pass
286+
logger.exception("ConPTY Ctrl+C failed")
263287

264288
def shutdown(self):
265-
if hasattr(self, '_timer') and self._timer:
266-
self._timer.stop()
267289
if self._pty and self._pty.isalive():
268290
try:
269291
self._pty.terminate()
@@ -294,6 +316,9 @@ def __init__(self, workspace_path, parent=None):
294316
def start(self):
295317
program, args = P.default_shell()
296318
self._process.start(program, args)
319+
# WHY: the widget queues typed commands until _shell_started is True.
320+
# Without this the fallback (no pywinpty) never ran any command.
321+
self._shell_started = True
297322
if P.IS_WINDOWS:
298323
# UTF-8 code page
299324
self._process.write(b"chcp 65001 >nul\r\n")
@@ -307,6 +332,7 @@ def _on_stderr(self):
307332
self.ready_read.emit(data)
308333

309334
def _on_finished(self, exit_code, exit_status):
335+
self._shell_started = False
310336
self.finished.emit(exit_code)
311337

312338
def _on_error(self, error):
@@ -317,11 +343,30 @@ def write(self, data: bytes):
317343
self._process.write(data)
318344

319345
def send_signal(self, sig):
320-
# QProcess doesn't support sending signals to process groups on Windows
321-
# On Unix we could use killpg, but we don't have the pgid easily.
322-
# For the fallback, we just kill the process.
323-
if self._process.state() != QProcess.ProcessState.NotRunning:
324-
self._process.kill()
346+
# WHAT: Ctrl+C stops the command running INSIDE the shell, not the shell.
347+
# HOW: a QProcess has no pseudo-terminal, so we find the shell's child
348+
# processes with psutil and terminate them; the shell itself survives
349+
# and prints its next prompt. If nothing is running, Ctrl+C is a no-op.
350+
if self._process.state() == QProcess.ProcessState.NotRunning:
351+
return
352+
try:
353+
import psutil
354+
children = psutil.Process(self._process.processId()).children(recursive=True)
355+
except Exception:
356+
logger.exception("Could not list terminal child processes")
357+
return
358+
for child in children:
359+
try:
360+
child.terminate()
361+
except Exception:
362+
pass # already exited between listing and terminating
363+
if children:
364+
_, alive = psutil.wait_procs(children, timeout=1.0)
365+
for child in alive:
366+
try:
367+
child.kill()
368+
except Exception:
369+
pass
325370

326371
def shutdown(self):
327372
if self._process.state() != QProcess.ProcessState.NotRunning:
@@ -340,8 +385,8 @@ def _create_pty_backend(workspace_path, parent=None) -> _PtyProcess:
340385
"""
341386
if P.IS_WINDOWS:
342387
try:
343-
import pywinpty
344-
_ = pywinpty # silence pyflakes "imported but unused"
388+
import winpty # module of the "pywinpty" package
389+
_ = winpty # silence pyflakes "imported but unused"
345390
return _WindowsConPtyProcess(workspace_path, parent)
346391
except ImportError:
347392
# pywinpty not installed — fall back to QProcess

‎packaging/windows/medha_windows.spec‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ datas = [(os.path.join(ROOT, "medha", "workbench.qss"), "medha"),
3535
if os.path.isdir(os.path.join(ROOT, "toolchains")):
3636
datas.append((os.path.join(ROOT, "toolchains"), "toolchains"))
3737
a = Analysis([os.path.join(ROOT, "packaging", "windows", "medha_entry.py")], pathex=[ROOT], datas=datas,
38-
hiddenimports=["medha.app", "PySide6.QtSvg"], excludes=["tkinter"])
38+
hiddenimports=["medha.app", "PySide6.QtSvg", "winpty", "psutil"], excludes=["tkinter"])
3939
pyz = PYZ(a.pure)
4040
exe = EXE(pyz, a.scripts, [], exclude_binaries=True, name="MEDHA", console=False, version=version_info,
4141
icon=os.path.join(ROOT, "packaging", "windows", "medha.ico") if os.path.exists(os.path.join(ROOT, "packaging", "windows", "medha.ico")) else None)

‎requirements.txt‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ PySide6>=6.12.0
88
psutil>=7.2.0
99

1010
# HTTP client for Open VSX Registry API (used in extensions/registry.py)
11-
requests>=2.34.0
11+
requests>=2.34.0
12+
13+
# Windows real terminal (ConPTY). pip name pywinpty, import name winpty (used in terminal.py)
14+
pywinpty>=2.0.13; sys_platform == "win32"

0 commit comments

Comments
 (0)