fix(windows): native ConPTY + kernel32 metrics + agent loop cap (v0.7.6)
All checks were successful
PR Check / check (pull_request) Successful in 1m0s
All checks were successful
PR Check / check (pull_request) Successful in 1m0s
Three issues reported on Windows + one user-requested limit bump:
1. Dashboard CPU/RAM/Network all at 0
handleSystemMetrics read /proc/* exclusively. Replaced with a
platform-split:
- metrics_unix.go (!windows): existing /proc reading code.
- metrics_windows.go: kernel32!GetSystemTimes for CPU
(delta of idle vs kernel+user FILETIMEs) and
kernel32!GlobalMemoryStatusEx for memory. Network left at zero
for now — MIB_IF_ROW2 is too version-sensitive to parse by hand.
handlers_info.go::handleSystemMetrics reduced to one delegating
call.
2. Terminal black screen on Windows
creack/pty/v2 returns "unsupported" on Windows; the v0.7.1 pipe
fallback works but pipes don't carry TTY signals, so cmd/pwsh/wsl
go silent. Implemented native ConPTY:
- terminal_conpty_windows.go: CreatePseudoConsole + STARTUPINFOEX
+ PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE wiring via
windows.NewProcThreadAttributeList. CreateProcessW launches
child with the PC attached, full ANSI / line discipline /
resize.
- canUseConPTY() probes once at startup (Win10 1809+ check).
- Restructure: terminal_session.go now holds just the interface
+ ptySession + pipeSession structs. terminal_session_unix.go
wires creack/pty. terminal_session_windows.go tries ConPTY
first, falls back to pipeSession.
3. Agent stops after 15 tool calls
MaxToolIterations bumped 15 → 500. Doc comment explains why the
cap exists at all (infinite-loop safety) and that 500 is well
above realistic usage.
- internal/version/version.go: 0.7.5 → 0.7.6
- CHANGELOG.md: v0.7.6 entry covers the three fixes
This commit is contained in:
31
CHANGELOG.md
31
CHANGELOG.md
@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## v0.7.6
|
||||||
|
|
||||||
|
### Trois fixes Windows + une amélioration agent
|
||||||
|
|
||||||
|
#### Métriques dashboard à 0 sur Windows
|
||||||
|
|
||||||
|
Symptôme : CPU / RAM / Réseau toujours à 0 dans le panneau Dashboard sous Windows. Cause : `handleSystemMetrics` lisait exclusivement `/proc/stat`, `/proc/meminfo`, `/proc/net/dev` — fichiers absents sur Windows, donc `os.ReadFile` échouait silencieusement et la struct restait à zéro.
|
||||||
|
|
||||||
|
Split en fichiers `_unix.go` / `_windows.go` :
|
||||||
|
- **`metrics_unix.go`** (`!windows`) : reprend tel quel le code `/proc/...` existant.
|
||||||
|
- **`metrics_windows.go`** : appelle `kernel32!GetSystemTimes` (CPU, ratio idle/total entre deux samples) et `kernel32!GlobalMemoryStatusEx` (RAM totale + dispo). Pas de spawn PowerShell, ~50 µs par appel. Réseau à zéro pour l'instant — `MIB_IF_ROW2` est trop sensible aux versions de Windows pour faire ça à la main proprement (TODO à part).
|
||||||
|
- `handleSystemMetrics` réduit à un appel à `collectSystemMetrics()`.
|
||||||
|
|
||||||
|
#### Terminal écran noir sur Windows
|
||||||
|
|
||||||
|
Symptôme : sous Windows native, le tab terminal ouvre la connexion mais l'écran reste noir, aucune sortie. Cause : `creack/pty/v2` retourne *"operating system not supported"* → fallback aux pipes. Pipes ne portent pas les signaux TTY, donc `cmd.exe` / `pwsh` / `wsl.exe` détectent l'absence de TTY et passent en mode silencieux ou attendent indéfiniment.
|
||||||
|
|
||||||
|
Implémentation **ConPTY** native via `kernel32!CreatePseudoConsole` (`internal/api/terminal_conpty_windows.go`) :
|
||||||
|
- Probe runtime `canUseConPTY()` (cache la disponibilité — Windows 10 1809+ requis).
|
||||||
|
- Crée un pseudo-console + 2 pipes anonymes, les passe au child via `STARTUPINFOEX` + `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` (utilise `windows.NewProcThreadAttributeList`).
|
||||||
|
- `CreateProcessW` lance le shell avec le PC attaché → ANSI / cursor / line discipline marchent comme sur un vrai TTY.
|
||||||
|
- `ResizePseudoConsole` câblé sur les events de redimensionnement xterm.
|
||||||
|
- Fallback `pipeSession` conservé si `canUseConPTY()` est false (Windows < 1809) ou si `startConptySession` échoue.
|
||||||
|
- Restructure des fichiers : `terminal_session.go` (interface + structs), `terminal_session_unix.go` (creack/pty), `terminal_session_windows.go` (ConPTY → pipe fallback), `terminal_conpty_windows.go` (impl).
|
||||||
|
|
||||||
|
#### Limite d'itérations d'outils agent
|
||||||
|
|
||||||
|
Symptôme : *"l'IA semble s'arrêter après 15 exécutions d'outils, je veux qu'elle puisse en faire 100, voire 1000"*. Cause : `MaxToolIterations = 15` dans `chat_engine.go`.
|
||||||
|
|
||||||
|
Bump : 15 → 500. Cap reste pour éviter les boucles infinies en cas de bug modèle, mais 500 itérations couvre largement les cas réels (refactor multi-fichiers, debug exploratoire). Documentation inline ajoutée pour expliquer pourquoi le cap existe et quand il faudrait s'inquiéter de le toucher.
|
||||||
|
|
||||||
## v0.7.5
|
## v0.7.5
|
||||||
|
|
||||||
### Fix Windows : commande `muyue` reconnue après install
|
### Fix Windows : commande `muyue` reconnue après install
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ import (
|
|||||||
"github.com/muyue/muyue/internal/orchestrator"
|
"github.com/muyue/muyue/internal/orchestrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// MaxToolIterations bounds the inner tool-call loop in RunWithTools /
|
||||||
MaxToolIterations = 15
|
// RunNonStream. The cap exists only to avoid an infinite loop when a model
|
||||||
)
|
// keeps calling tools forever; the value is intentionally generous so a
|
||||||
|
// realistic agent run (multi-file refactor, exploratory debugging…) never
|
||||||
|
// hits it. If you find yourself raising this to absurd values, look for a
|
||||||
|
// loop bug in the model output instead.
|
||||||
|
const MaxToolIterations = 500
|
||||||
|
|
||||||
// ToolLimiter checks if a tool call is allowed and returns a release function.
|
// ToolLimiter checks if a tool call is allowed and returns a release function.
|
||||||
type ToolLimiter func(toolName string) (release func(), err error)
|
type ToolLimiter func(toolName string) (release func(), err error)
|
||||||
|
|||||||
@@ -756,93 +756,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
m := sysMetrics{}
|
m := collectSystemMetrics()
|
||||||
|
|
||||||
// CPU from /proc/stat
|
|
||||||
if data, err := os.ReadFile("/proc/stat"); err == nil {
|
|
||||||
line := strings.Split(string(data), "\n")[0]
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) >= 5 {
|
|
||||||
var idle, total float64
|
|
||||||
for i := 1; i < len(fields) && i <= 4; i++ {
|
|
||||||
var v float64
|
|
||||||
fmt.Sscanf(fields[i], "%f", &v)
|
|
||||||
total += v
|
|
||||||
if i == 4 {
|
|
||||||
idle = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if lastCPUSet {
|
|
||||||
dIdle := idle - lastCPU[0]
|
|
||||||
dTotal := total - lastCPU[1]
|
|
||||||
if dTotal > 0 {
|
|
||||||
m.CPUPercent = (1 - dIdle/dTotal) * 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastCPU = [2]float64{idle, total}
|
|
||||||
lastCPUSet = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory from /proc/meminfo
|
|
||||||
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
|
||||||
var memTotal, memAvailable float64
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var v float64
|
|
||||||
fmt.Sscanf(fields[1], "%f", &v)
|
|
||||||
switch fields[0] {
|
|
||||||
case "MemTotal:":
|
|
||||||
memTotal = v
|
|
||||||
case "MemAvailable:":
|
|
||||||
memAvailable = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if memTotal > 0 {
|
|
||||||
m.MemTotalMB = memTotal / 1024
|
|
||||||
m.MemUsedMB = (memTotal - memAvailable) / 1024
|
|
||||||
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network from /proc/net/dev
|
|
||||||
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
|
||||||
var rxBytes, txBytes float64
|
|
||||||
for _, line := range strings.Split(string(data), "\n")[2:] {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 10 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
iface := strings.TrimSuffix(fields[0], ":")
|
|
||||||
if iface == "lo" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var rx, tx float64
|
|
||||||
fmt.Sscanf(fields[1], "%f", &rx)
|
|
||||||
fmt.Sscanf(fields[9], "%f", &tx)
|
|
||||||
rxBytes += rx
|
|
||||||
txBytes += tx
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
if !lastNetTs.IsZero() {
|
|
||||||
elapsed := now.Sub(lastNetTs).Seconds()
|
|
||||||
if elapsed > 0 {
|
|
||||||
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
|
|
||||||
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
|
|
||||||
if m.NetRxKBs < 0 {
|
|
||||||
m.NetRxKBs = 0
|
|
||||||
}
|
|
||||||
if m.NetTxKBs < 0 {
|
|
||||||
m.NetTxKBs = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastNet = [2]float64{rxBytes, txBytes}
|
|
||||||
lastNetTs = now
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, m)
|
writeJSON(w, m)
|
||||||
}
|
}
|
||||||
|
|||||||
106
internal/api/metrics_unix.go
Normal file
106
internal/api/metrics_unix.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// collectSystemMetrics reads /proc on Linux. On macOS / BSD this returns
|
||||||
|
// zeroes for files that don't exist — the dashboard panel renders blanks
|
||||||
|
// rather than crashing. macOS-specific metrics could be added later via
|
||||||
|
// `vm_stat` / `iostat` parsing.
|
||||||
|
func collectSystemMetrics() sysMetrics {
|
||||||
|
m := sysMetrics{}
|
||||||
|
|
||||||
|
// CPU from /proc/stat
|
||||||
|
if data, err := os.ReadFile("/proc/stat"); err == nil {
|
||||||
|
line := strings.Split(string(data), "\n")[0]
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 5 {
|
||||||
|
var idle, total float64
|
||||||
|
for i := 1; i < len(fields) && i <= 4; i++ {
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(fields[i], "%f", &v)
|
||||||
|
total += v
|
||||||
|
if i == 4 {
|
||||||
|
idle = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastCPUSet {
|
||||||
|
dIdle := idle - lastCPU[0]
|
||||||
|
dTotal := total - lastCPU[1]
|
||||||
|
if dTotal > 0 {
|
||||||
|
m.CPUPercent = (1 - dIdle/dTotal) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastCPU = [2]float64{idle, total}
|
||||||
|
lastCPUSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory from /proc/meminfo
|
||||||
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
var memTotal, memAvailable float64
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(fields[1], "%f", &v)
|
||||||
|
switch fields[0] {
|
||||||
|
case "MemTotal:":
|
||||||
|
memTotal = v
|
||||||
|
case "MemAvailable:":
|
||||||
|
memAvailable = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memTotal > 0 {
|
||||||
|
m.MemTotalMB = memTotal / 1024
|
||||||
|
m.MemUsedMB = (memTotal - memAvailable) / 1024
|
||||||
|
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network from /proc/net/dev
|
||||||
|
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
||||||
|
var rxBytes, txBytes float64
|
||||||
|
for _, line := range strings.Split(string(data), "\n")[2:] {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iface := strings.TrimSuffix(fields[0], ":")
|
||||||
|
if iface == "lo" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rx, tx float64
|
||||||
|
fmt.Sscanf(fields[1], "%f", &rx)
|
||||||
|
fmt.Sscanf(fields[9], "%f", &tx)
|
||||||
|
rxBytes += rx
|
||||||
|
txBytes += tx
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if !lastNetTs.IsZero() {
|
||||||
|
elapsed := now.Sub(lastNetTs).Seconds()
|
||||||
|
if elapsed > 0 {
|
||||||
|
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
|
||||||
|
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
|
||||||
|
if m.NetRxKBs < 0 {
|
||||||
|
m.NetRxKBs = 0
|
||||||
|
}
|
||||||
|
if m.NetTxKBs < 0 {
|
||||||
|
m.NetTxKBs = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastNet = [2]float64{rxBytes, txBytes}
|
||||||
|
lastNetTs = now
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
129
internal/api/metrics_windows.go
Normal file
129
internal/api/metrics_windows.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// collectSystemMetrics reads CPU% and memory from kernel32 directly.
|
||||||
|
// Network throughput on Windows is left at zero for now — the iphlpapi
|
||||||
|
// MIB_IF_ROW2 layout is large and version-sensitive; reliable net stats
|
||||||
|
// would warrant a separate, well-tested implementation. CPU + RAM are
|
||||||
|
// enough for the dashboard's main signal.
|
||||||
|
func collectSystemMetrics() sysMetrics {
|
||||||
|
m := sysMetrics{}
|
||||||
|
|
||||||
|
if cpu, ok := readWindowsCPUPercent(); ok {
|
||||||
|
m.CPUPercent = cpu
|
||||||
|
}
|
||||||
|
if memTotalMB, memUsedMB, memPct, ok := readWindowsMemory(); ok {
|
||||||
|
m.MemTotalMB = memTotalMB
|
||||||
|
m.MemUsedMB = memUsedMB
|
||||||
|
m.MemPercent = memPct
|
||||||
|
}
|
||||||
|
// Net: zero (TODO).
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CPU ---------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
cpuOnce sync.Once
|
||||||
|
getSystemTimes *syscall.LazyProc
|
||||||
|
lastWinCPUIdle uint64
|
||||||
|
lastWinCPUTotal uint64
|
||||||
|
lastWinCPUSet bool
|
||||||
|
winCPUMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadCPUFns() {
|
||||||
|
cpuOnce.Do(func() {
|
||||||
|
k := syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
getSystemTimes = k.NewProc("GetSystemTimes")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func filetimeToUint64(low, high uint32) uint64 {
|
||||||
|
return uint64(high)<<32 | uint64(low)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readWindowsCPUPercent samples GetSystemTimes twice and computes the busy
|
||||||
|
// ratio as 1 - dIdle / (dKernel + dUser). The first call returns 0% and
|
||||||
|
// stores the baseline; subsequent calls return the delta-based percentage.
|
||||||
|
func readWindowsCPUPercent() (float64, bool) {
|
||||||
|
loadCPUFns()
|
||||||
|
if getSystemTimes == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var idle, kernel, user windows.Filetime
|
||||||
|
r1, _, _ := getSystemTimes.Call(
|
||||||
|
uintptr(unsafe.Pointer(&idle)),
|
||||||
|
uintptr(unsafe.Pointer(&kernel)),
|
||||||
|
uintptr(unsafe.Pointer(&user)),
|
||||||
|
)
|
||||||
|
if r1 == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
idleT := filetimeToUint64(idle.LowDateTime, idle.HighDateTime)
|
||||||
|
totalT := filetimeToUint64(kernel.LowDateTime, kernel.HighDateTime) +
|
||||||
|
filetimeToUint64(user.LowDateTime, user.HighDateTime)
|
||||||
|
winCPUMu.Lock()
|
||||||
|
defer winCPUMu.Unlock()
|
||||||
|
if !lastWinCPUSet {
|
||||||
|
lastWinCPUIdle = idleT
|
||||||
|
lastWinCPUTotal = totalT
|
||||||
|
lastWinCPUSet = true
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
dIdle := idleT - lastWinCPUIdle
|
||||||
|
dTotal := totalT - lastWinCPUTotal
|
||||||
|
lastWinCPUIdle = idleT
|
||||||
|
lastWinCPUTotal = totalT
|
||||||
|
if dTotal == 0 {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
pct := (1 - float64(dIdle)/float64(dTotal)) * 100
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
} else if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
return pct, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Memory ------------------------------------------------------------
|
||||||
|
|
||||||
|
type memoryStatusEx struct {
|
||||||
|
Length uint32
|
||||||
|
MemoryLoad uint32
|
||||||
|
TotalPhys uint64
|
||||||
|
AvailPhys uint64
|
||||||
|
TotalPageFile uint64
|
||||||
|
AvailPageFile uint64
|
||||||
|
TotalVirtual uint64
|
||||||
|
AvailVirtual uint64
|
||||||
|
AvailExtendedVirtual uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalMemoryStatusEx = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
|
||||||
|
|
||||||
|
func readWindowsMemory() (totalMB, usedMB, percent float64, ok bool) {
|
||||||
|
var ms memoryStatusEx
|
||||||
|
ms.Length = uint32(unsafe.Sizeof(ms))
|
||||||
|
r1, _, _ := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms)))
|
||||||
|
if r1 == 0 {
|
||||||
|
return 0, 0, 0, false
|
||||||
|
}
|
||||||
|
const mb = 1024 * 1024
|
||||||
|
totalMB = float64(ms.TotalPhys) / mb
|
||||||
|
usedMB = float64(ms.TotalPhys-ms.AvailPhys) / mb
|
||||||
|
if ms.TotalPhys > 0 {
|
||||||
|
percent = float64(ms.TotalPhys-ms.AvailPhys) * 100 / float64(ms.TotalPhys)
|
||||||
|
}
|
||||||
|
return totalMB, usedMB, percent, true
|
||||||
|
}
|
||||||
265
internal/api/terminal_conpty_windows.go
Normal file
265
internal/api/terminal_conpty_windows.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
// Windows ConPTY (Pseudo Console) backend for the terminal tab.
|
||||||
|
//
|
||||||
|
// creack/pty/v2 returns "operating system not supported" on Windows, so the
|
||||||
|
// previous fallback was plain stdin/stdout pipes (terminal_session.go::
|
||||||
|
// pipeSession). Pipes don't carry TTY signals, so cmd.exe / pwsh / wsl
|
||||||
|
// detect "no TTY" and either go silent or wait forever — the user sees a
|
||||||
|
// black screen. This file implements a real pseudo console using the
|
||||||
|
// kernel32 ConPTY API, so the spawned shell behaves as if it were attached
|
||||||
|
// to a real terminal: prompts render, ANSI escapes are honoured, resize
|
||||||
|
// events propagate.
|
||||||
|
//
|
||||||
|
// Requires Windows 10 v1809 (build 17763) or newer. On older hosts
|
||||||
|
// CreatePseudoConsole returns an error and startTermSession_windows falls
|
||||||
|
// back to pipeSession.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
procThreadAttributePseudoconsole = 0x00020016
|
||||||
|
extendedStartupinfoPresent = 0x00080000
|
||||||
|
createUnicodeEnvironment = 0x00000400
|
||||||
|
)
|
||||||
|
|
||||||
|
// conptySession drives a Windows pseudo console.
|
||||||
|
type conptySession struct {
|
||||||
|
hPC windows.Handle
|
||||||
|
inWrite windows.Handle
|
||||||
|
outRead windows.Handle
|
||||||
|
procInfo windows.ProcessInformation
|
||||||
|
closed bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// startConptySession spins up the pseudo console, plumbs the pipes, and
|
||||||
|
// CreateProcessW's the child with the PC attached via STARTUPINFOEX.
|
||||||
|
func startConptySession(cmd *exec.Cmd) (termSession, error) {
|
||||||
|
// 1. Two pipe pairs: in (we write → child stdin) and out (child stdout → we read).
|
||||||
|
var inRead, inWrite, outRead, outWrite windows.Handle
|
||||||
|
if err := windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil {
|
||||||
|
return nil, fmt.Errorf("create stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
if err := windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil {
|
||||||
|
windows.CloseHandle(inRead)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
return nil, fmt.Errorf("create stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create the pseudo console. After this call ConPTY effectively owns
|
||||||
|
// the child-facing pipe ends (inRead, outWrite); we close our copy.
|
||||||
|
var hPC windows.Handle
|
||||||
|
sz := windows.Coord{X: 120, Y: 30}
|
||||||
|
if err := windows.CreatePseudoConsole(sz, inRead, outWrite, 0, &hPC); err != nil {
|
||||||
|
windows.CloseHandle(inRead)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
windows.CloseHandle(outWrite)
|
||||||
|
return nil, fmt.Errorf("CreatePseudoConsole: %w", err)
|
||||||
|
}
|
||||||
|
windows.CloseHandle(inRead)
|
||||||
|
windows.CloseHandle(outWrite)
|
||||||
|
|
||||||
|
// 3. Allocate an attribute list with one slot for the PC attribute.
|
||||||
|
attrList, err := windows.NewProcThreadAttributeList(1)
|
||||||
|
if err != nil {
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, fmt.Errorf("NewProcThreadAttributeList: %w", err)
|
||||||
|
}
|
||||||
|
if err := attrList.Update(
|
||||||
|
procThreadAttributePseudoconsole,
|
||||||
|
unsafe.Pointer(&hPC),
|
||||||
|
unsafe.Sizeof(hPC),
|
||||||
|
); err != nil {
|
||||||
|
attrList.Delete()
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, fmt.Errorf("attrList.Update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build command line.
|
||||||
|
cmdLine, err := buildCommandLine(cmd)
|
||||||
|
if err != nil {
|
||||||
|
attrList.Delete()
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmdLineUTF16, err := windows.UTF16PtrFromString(cmdLine)
|
||||||
|
if err != nil {
|
||||||
|
attrList.Delete()
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build the env block (key=value\0...\0\0).
|
||||||
|
var envBlock *uint16
|
||||||
|
if cmd.Env != nil {
|
||||||
|
eb, err := makeEnvBlock(cmd.Env)
|
||||||
|
if err != nil {
|
||||||
|
attrList.Delete()
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
envBlock = eb
|
||||||
|
}
|
||||||
|
|
||||||
|
si := windows.StartupInfoEx{}
|
||||||
|
si.StartupInfo.Cb = uint32(unsafe.Sizeof(si))
|
||||||
|
si.ProcThreadAttributeList = attrList.List()
|
||||||
|
|
||||||
|
flags := uint32(extendedStartupinfoPresent)
|
||||||
|
if envBlock != nil {
|
||||||
|
flags |= createUnicodeEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
|
var pi windows.ProcessInformation
|
||||||
|
err = windows.CreateProcess(
|
||||||
|
nil, // application name (null = parse from cmdline)
|
||||||
|
cmdLineUTF16,
|
||||||
|
nil, // process security attrs
|
||||||
|
nil, // thread security attrs
|
||||||
|
false, // inherit handles (ConPTY hands handles via attribute list)
|
||||||
|
flags,
|
||||||
|
envBlock,
|
||||||
|
nil, // working dir
|
||||||
|
&si.StartupInfo,
|
||||||
|
&pi,
|
||||||
|
)
|
||||||
|
attrList.Delete()
|
||||||
|
if err != nil {
|
||||||
|
windows.ClosePseudoConsole(hPC)
|
||||||
|
windows.CloseHandle(inWrite)
|
||||||
|
windows.CloseHandle(outRead)
|
||||||
|
return nil, fmt.Errorf("CreateProcess: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &conptySession{
|
||||||
|
hPC: hPC,
|
||||||
|
inWrite: inWrite,
|
||||||
|
outRead: outRead,
|
||||||
|
procInfo: pi,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Read(p []byte) (int, error) {
|
||||||
|
var n uint32
|
||||||
|
err := windows.ReadFile(s.outRead, p, &n, nil)
|
||||||
|
if err != nil {
|
||||||
|
if n > 0 {
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Write(p []byte) (int, error) {
|
||||||
|
var n uint32
|
||||||
|
err := windows.WriteFile(s.inWrite, p, &n, nil)
|
||||||
|
if err != nil {
|
||||||
|
return int(n), err
|
||||||
|
}
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Resize(rows, cols uint16) error {
|
||||||
|
return windows.ResizePseudoConsole(s.hPC, windows.Coord{X: int16(cols), Y: int16(rows)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Close() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Order matters: close the pseudo console first so the child sees EOF,
|
||||||
|
// then close our pipe ends, then terminate / close handles.
|
||||||
|
windows.ClosePseudoConsole(s.hPC)
|
||||||
|
windows.CloseHandle(s.inWrite)
|
||||||
|
windows.CloseHandle(s.outRead)
|
||||||
|
if s.procInfo.Process != 0 {
|
||||||
|
windows.TerminateProcess(s.procInfo.Process, 0)
|
||||||
|
windows.CloseHandle(s.procInfo.Process)
|
||||||
|
}
|
||||||
|
if s.procInfo.Thread != 0 {
|
||||||
|
windows.CloseHandle(s.procInfo.Thread)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Wait() error {
|
||||||
|
if s.procInfo.Process == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := windows.WaitForSingleObject(s.procInfo.Process, windows.INFINITE)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conptySession) Pid() int {
|
||||||
|
return int(s.procInfo.ProcessId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
// buildCommandLine produces the Windows command-line string for an
|
||||||
|
// *exec.Cmd, mirroring what os/exec uses internally (escaping spaces and
|
||||||
|
// quotes per Windows convention).
|
||||||
|
func buildCommandLine(cmd *exec.Cmd) (string, error) {
|
||||||
|
if cmd.Path == "" {
|
||||||
|
return "", fmt.Errorf("empty cmd.Path")
|
||||||
|
}
|
||||||
|
parts := []string{cmd.Path}
|
||||||
|
if len(cmd.Args) > 1 {
|
||||||
|
parts = append(parts, cmd.Args[1:]...)
|
||||||
|
}
|
||||||
|
out := syscall.EscapeArg(parts[0])
|
||||||
|
for _, a := range parts[1:] {
|
||||||
|
out += " " + syscall.EscapeArg(a)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeEnvBlock packs a Go environ slice into the Windows UTF-16 env block
|
||||||
|
// format: key=value\0key=value\0\0.
|
||||||
|
func makeEnvBlock(env []string) (*uint16, error) {
|
||||||
|
var buf []uint16
|
||||||
|
for _, kv := range env {
|
||||||
|
s, err := syscall.UTF16FromString(kv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
buf = append(buf, s...) // includes trailing NUL
|
||||||
|
}
|
||||||
|
buf = append(buf, 0) // final terminator
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &buf[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time interface assertion.
|
||||||
|
var _ termSession = (*conptySession)(nil)
|
||||||
@@ -2,19 +2,22 @@ package api
|
|||||||
|
|
||||||
// Cross-platform terminal session abstraction.
|
// Cross-platform terminal session abstraction.
|
||||||
//
|
//
|
||||||
// On Linux / macOS we have a real PTY via creack/pty: full TTY semantics,
|
// On Linux / macOS the unix-tagged file (terminal_session_unix.go) wires
|
||||||
// resize support, interactive apps (vim, top…) work. On Windows the same
|
// startTermSession to creack/pty for a real PTY: full TTY semantics,
|
||||||
// package returns "operating system not supported" at pty.Start time, so we
|
// resize support, interactive apps (vim, top…) work.
|
||||||
// fall back to plain pipes (stdin / stdout merged with stderr). Pipes don't
|
//
|
||||||
// give a real TTY — interactive TUIs misbehave — but `wsl`, `pwsh`, `cmd`,
|
// On Windows the windows-tagged file (terminal_session_windows.go) tries
|
||||||
// and most CLI tools emit usable line-buffered output, which is what the
|
// the kernel32 ConPTY API first, with a pipe-based fallback for older
|
||||||
// user actually clicks for.
|
// hosts. pipeSession does NOT carry TTY signals, so most shells go silent
|
||||||
|
// — it's only kept as a last resort.
|
||||||
|
//
|
||||||
|
// Both platforms share the termSession interface, the ptySession type
|
||||||
|
// (used by unix), and the pipeSession type (used by the Windows fallback).
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/creack/pty/v2"
|
"github.com/creack/pty/v2"
|
||||||
@@ -30,22 +33,7 @@ type termSession interface {
|
|||||||
Pid() int
|
Pid() int
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTermSession tries a real PTY first; on Windows or any pty.Start failure
|
// ptySession wraps creack/pty's *os.File-backed PTY (unix path).
|
||||||
// it falls back to a pipe-based session.
|
|
||||||
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
ptmx, err := pty.Start(cmd)
|
|
||||||
if err == nil {
|
|
||||||
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
|
|
||||||
}
|
|
||||||
// On unix, a pty.Start error is fatal — pipes won't help interactive
|
|
||||||
// shells without a TTY, and the unix build is the supported path.
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return startPipeSession(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ptySession wraps creack/pty's *os.File-backed PTY.
|
|
||||||
type ptySession struct {
|
type ptySession struct {
|
||||||
ptmx *os.File
|
ptmx *os.File
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
@@ -76,8 +64,10 @@ func (s *ptySession) Pid() int {
|
|||||||
return s.cmd.Process.Pid
|
return s.cmd.Process.Pid
|
||||||
}
|
}
|
||||||
|
|
||||||
// pipeSession is the Windows fallback: stdin pipe + merged stdout/stderr pipe,
|
// pipeSession is the Windows last-resort fallback when ConPTY is not
|
||||||
// running concurrently. Resize is a no-op (no TTY to send TIOCSWINSZ to).
|
// available: stdin pipe + merged stdout/stderr, no TTY signals. Most
|
||||||
|
// interactive shells go silent in this mode, so it should rarely be hit on
|
||||||
|
// modern Windows (10 1809+).
|
||||||
type pipeSession struct {
|
type pipeSession struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
stdin io.WriteCloser
|
stdin io.WriteCloser
|
||||||
@@ -115,7 +105,7 @@ func startPipeSession(cmd *exec.Cmd) (termSession, error) {
|
|||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
stdin: stdin,
|
stdin: stdin,
|
||||||
stdout: stdout,
|
stdout: stdout,
|
||||||
stderr: stderr,
|
stderr: stderr,
|
||||||
merged: make(chan []byte, 32),
|
merged: make(chan []byte, 32),
|
||||||
closeCh: make(chan struct{}),
|
closeCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|||||||
19
internal/api/terminal_session_unix.go
Normal file
19
internal/api/terminal_session_unix.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/creack/pty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startTermSession (unix) opens a real PTY via creack/pty. Fatal on error
|
||||||
|
// — the unix build assumes PTY availability.
|
||||||
|
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||||
|
ptmx, err := pty.Start(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ptySession{ptmx: ptmx, cmd: cmd}, nil
|
||||||
|
}
|
||||||
20
internal/api/terminal_session_windows.go
Normal file
20
internal/api/terminal_session_windows.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startTermSession (windows) tries the kernel32 ConPTY API first. ConPTY
|
||||||
|
// gives a real pseudo terminal, so wsl.exe / pwsh / cmd render their
|
||||||
|
// prompt and the user can interact normally. If ConPTY is unavailable
|
||||||
|
// (Windows < 10 1809) or the call fails for any reason, we fall back to
|
||||||
|
// the line-buffered pipe session — degraded but functional for non-TUI
|
||||||
|
// commands.
|
||||||
|
func startTermSession(cmd *exec.Cmd) (termSession, error) {
|
||||||
|
if sess, err := startConptySession(cmd); err == nil {
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
return startPipeSession(cmd)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Name = "muyue"
|
Name = "muyue"
|
||||||
Version = "0.7.5"
|
Version = "0.7.6"
|
||||||
Author = "La Légion de Muyue"
|
Author = "La Légion de Muyue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user