diff --git a/README.md b/README.md index f9b6bbe..d5b2f85 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![CI](https://github.com/wavever/CCLimitPing/actions/workflows/ci.yml/badge.svg)](https://github.com/wavever/CCLimitPing/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/wavever/CCLimitPing?include_prereleases&sort=semver)](https://github.com/wavever/CCLimitPing/releases) ![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white) -![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey) +![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey) Start the next **Claude Code**, **Codex**, or **Spark** rate-limit window the moment the previous one resets. @@ -154,6 +154,12 @@ tar -xzf limitping_darwin_arm64.tar.gz sudo mv limitping /usr/local/bin/ ``` +**Windows** — use the manual download above (`limitping_windows_amd64.zip` or +`limitping_windows_arm64.zip`). The Claude Code and Codex interactive triggers +run natively through a ConPTY pseudo-console, including CLIs installed as +`.cmd`/`.bat` shims. Not every command is available on Windows yet — +`limitping continue` still reports that it is unsupported. + **Homebrew** (macOS / Linux) — `brew install wavever/tap/limitping` _(works once the Homebrew tap is set up — see `.goreleaser.yaml`)._ diff --git a/README.zh-CN.md b/README.zh-CN.md index c64cf41..fc425f8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ [![CI](https://github.com/wavever/CCLimitPing/actions/workflows/ci.yml/badge.svg)](https://github.com/wavever/CCLimitPing/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/wavever/CCLimitPing?include_prereleases&sort=semver)](https://github.com/wavever/CCLimitPing/releases) ![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go&logoColor=white) -![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey) +![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey) 在上一个窗口重置的瞬间,立即启动下一个 **Claude Code** / **Codex** / **Spark** 限额窗口。 @@ -138,6 +138,11 @@ tar -xzf limitping_darwin_arm64.tar.gz sudo mv limitping /usr/local/bin/ ``` +**Windows** —— 使用上面的手动下载(`limitping_windows_amd64.zip` 或 +`limitping_windows_arm64.zip`)。Claude Code 与 Codex 的交互式触发 +通过原生 ConPTY 伪控制台运行,也支持以 `.cmd`/`.bat` shim 安装的 CLI。 +Windows 上并非所有命令都已可用 —— `limitping continue` 仍会提示暂不支持。 + **Homebrew**(macOS / Linux)—— `brew install wavever/tap/limitping` _(配好 Homebrew tap 后可用;见 `.goreleaser.yaml`)。_ diff --git a/go.mod b/go.mod index a92bd5b..0e7899c 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,11 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/creack/pty v1.1.24 github.com/spf13/cobra v1.10.2 + golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.46.0 // indirect ) diff --git a/internal/provider/claude.go b/internal/provider/claude.go index 11f825e..cd4dc4d 100644 --- a/internal/provider/claude.go +++ b/internal/provider/claude.go @@ -7,18 +7,16 @@ import ( "fmt" "io" "net/http" - "os" "os/exec" "regexp" "strings" "sync" "time" - "github.com/creack/pty" - "github.com/wavever/CCLimitPing/internal/activity" "github.com/wavever/CCLimitPing/internal/auth" "github.com/wavever/CCLimitPing/internal/config" + "github.com/wavever/CCLimitPing/internal/terminal" "github.com/wavever/CCLimitPing/internal/usage" ) @@ -272,58 +270,55 @@ func (c *Claude) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, erro return res, nil } - cmd := exec.CommandContext(ctx, "claude", args...) - ptmx, err := pty.Start(cmd) + sess, err := terminal.Start(ctx, "claude", args) if err != nil { return res, fmt.Errorf("claude interactive failed to start: %w", err) } - defer ptmx.Close() + defer sess.Close() output := &limitedBuffer{limit: 4096} go func() { - _, _ = io.Copy(output, ptmx) + _, _ = io.Copy(output, sess) }() done := make(chan error, 1) go func() { - done <- cmd.Wait() + done <- sess.Wait() }() // Phase 1: wait for the TUI to render and settle so the submit Enter lands on // a ready prompt holding the prefilled message. - if terminal, err := claudeAwait(ctx, cmd, ptmx, output, done, claudeStartupTimeout, - func(idle, _ time.Duration) bool { return idle >= claudeStartupSettle }); terminal { + if term, err := claudeAwait(ctx, sess, output, done, claudeStartupTimeout, + func(idle, _ time.Duration) bool { return idle >= claudeStartupSettle }); term { return res, err } // Submit the prefilled prompt. This is the model request that anchors the 5h // window; the previous implementation never sent it, so the window never // started even though the session exited cleanly. - if _, werr := ptmx.Write([]byte("\r")); werr != nil { + if _, werr := sess.Write([]byte("\r")); werr != nil { return res, fmt.Errorf("claude interactive failed to submit prompt: %w: %s", werr, truncate(output.Bytes(), 300)) } // Phase 2: let the turn run until its output goes quiet (bounded by a floor // and a hard cap), so we don't cancel the in-flight request by exiting early. - if terminal, err := claudeAwait(ctx, cmd, ptmx, output, done, claudeTurnMaxWait, + if term, err := claudeAwait(ctx, sess, output, done, claudeTurnMaxWait, func(idle, elapsed time.Duration) bool { return elapsed >= claudeTurnMinWait && idle >= claudeTurnQuiet - }); terminal { + }); term { return res, err } // Phase 3: quit. The window is already anchored, so a messy shutdown here // must not fail the ping. - _, _ = ptmx.Write([]byte("/exit\r")) + _, _ = sess.Write([]byte("/exit\r")) select { case err := <-done: return res, claudeInteractiveErr(err, output) case <-ctx.Done(): - return res, claudeInteractiveCancel(ctx, cmd, ptmx, done, output) + return res, claudeInteractiveCancel(ctx, sess, done, output) case <-time.After(claudeExitGrace): - if cmd.Process != nil { - _ = cmd.Process.Kill() - } + _ = sess.Kill() select { case <-done: case <-time.After(time.Second): @@ -340,7 +335,7 @@ func (c *Claude) Trigger(ctx context.Context, dryRun bool) (*TriggerResult, erro // PTY output and elapsed is the time since this phase began. It returns // terminal=true (with an error to propagate) only if the process exits or ctx is // cancelled first; otherwise terminal=false and the caller continues. -func claudeAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) { +func claudeAwait(ctx context.Context, sess terminal.Session, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) { start := time.Now() deadline := time.After(maxWait) ticker := time.NewTicker(claudePollInterval) @@ -350,7 +345,7 @@ func claudeAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limi case err := <-done: return true, claudeInteractiveErr(err, output) case <-ctx.Done(): - return true, claudeInteractiveCancel(ctx, cmd, ptmx, done, output) + return true, claudeInteractiveCancel(ctx, sess, done, output) case <-deadline: return false, nil case <-ticker.C: @@ -386,11 +381,9 @@ func claudeSubscriptionErrorFromOutput(raw []byte) error { return nil } -func claudeInteractiveCancel(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer) error { - if cmd.Process != nil { - _ = cmd.Process.Kill() - } - _ = ptmx.Close() +func claudeInteractiveCancel(ctx context.Context, sess terminal.Session, done <-chan error, output *limitedBuffer) error { + _ = sess.Kill() + _ = sess.Close() select { case <-done: case <-time.After(time.Second): diff --git a/internal/provider/codex.go b/internal/provider/codex.go index 7fb0c45..a15d50c 100644 --- a/internal/provider/codex.go +++ b/internal/provider/codex.go @@ -12,7 +12,6 @@ import ( "net/http" "net/url" "os" - "os/exec" "path/filepath" "strings" "sync" @@ -20,11 +19,11 @@ import ( "unicode" "github.com/BurntSushi/toml" - "github.com/creack/pty" "github.com/wavever/CCLimitPing/internal/activity" "github.com/wavever/CCLimitPing/internal/auth" "github.com/wavever/CCLimitPing/internal/config" + "github.com/wavever/CCLimitPing/internal/terminal" "github.com/wavever/CCLimitPing/internal/usage" ) @@ -569,34 +568,33 @@ func triggerCodex(ctx context.Context, cfg config.ProviderConfig, dryRun bool) ( return res, nil } - cmd := exec.CommandContext(ctx, "codex", args...) - ptmx, err := pty.Start(cmd) + sess, err := terminal.Start(ctx, "codex", args) if err != nil { return res, fmt.Errorf("codex interactive failed to start: %w", err) } - defer ptmx.Close() + defer sess.Close() output := &limitedBuffer{limit: 4096} go func() { - _, _ = io.Copy(output, ptmx) + _, _ = io.Copy(output, sess) }() done := make(chan error, 1) go func() { - done <- cmd.Wait() + done <- sess.Wait() }() - if terminal, err := codexAwait(ctx, cmd, ptmx, output, done, codexTurnMaxWait, + if term, err := codexAwait(ctx, sess, output, done, codexTurnMaxWait, func(idle, elapsed time.Duration) bool { return elapsed >= codexTurnMinWait && idle >= codexTurnQuiet - }); terminal { + }); term { return res, err } - return res, codexInteractiveStop(ctx, cmd, ptmx, done, output) + return res, codexInteractiveStop(ctx, sess, done, output) } -func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) { +func codexAwait(ctx context.Context, sess terminal.Session, output *limitedBuffer, done <-chan error, maxWait time.Duration, ready func(idle, elapsed time.Duration) bool) (bool, error) { start := time.Now() deadline := time.After(maxWait) ticker := time.NewTicker(codexPollInterval) @@ -606,7 +604,7 @@ func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limit case err := <-done: return true, codexInteractiveErr(err, output) case <-ctx.Done(): - return true, codexInteractiveCancel(ctx, cmd, ptmx, done, output) + return true, codexInteractiveCancel(ctx, sess, done, output) case <-deadline: return false, nil case <-ticker.C: @@ -618,27 +616,25 @@ func codexAwait(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, output *limit } } -func codexInteractiveStop(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer) error { +func codexInteractiveStop(ctx context.Context, sess terminal.Session, done <-chan error, output *limitedBuffer) error { deadline := time.After(codexExitGrace) ticker := time.NewTicker(codexExitGrace / 2) defer ticker.Stop() for sent := false; ; { if !sent { - _, _ = ptmx.Write([]byte{0x03}) + _, _ = sess.Write([]byte{0x03}) sent = true } select { case <-done: return nil case <-ctx.Done(): - return codexInteractiveCancel(ctx, cmd, ptmx, done, output) + return codexInteractiveCancel(ctx, sess, done, output) case <-ticker.C: - _, _ = ptmx.Write([]byte{0x03}) + _, _ = sess.Write([]byte{0x03}) case <-deadline: - if cmd.Process != nil { - _ = cmd.Process.Kill() - } + _ = sess.Kill() select { case <-done: case <-time.After(time.Second): @@ -659,11 +655,9 @@ func codexInteractiveErr(err error, output *limitedBuffer) error { return fmt.Errorf("codex interactive failed: %w: %s", err, tail) } -func codexInteractiveCancel(ctx context.Context, cmd *exec.Cmd, ptmx *os.File, done <-chan error, output *limitedBuffer) error { - if cmd.Process != nil { - _ = cmd.Process.Kill() - } - _ = ptmx.Close() +func codexInteractiveCancel(ctx context.Context, sess terminal.Session, done <-chan error, output *limitedBuffer) error { + _ = sess.Kill() + _ = sess.Close() select { case <-done: case <-time.After(time.Second): diff --git a/internal/terminal/conpty_windows.go b/internal/terminal/conpty_windows.go new file mode 100644 index 0000000..c39aa94 --- /dev/null +++ b/internal/terminal/conpty_windows.go @@ -0,0 +1,358 @@ +//go:build windows + +package terminal + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// conPTYSize is the size every pseudoconsole is created with. The interactive +// CLIs only need a plausible terminal to lay their TUI out in, and nothing here +// resizes, so this is fixed rather than tracked against a real console: the ping +// paths run detached, where there is no console to track. +var conPTYSize = windows.Coord{X: 120, Y: 30} + +const ( + // conhostExitTimeout bounds how long teardown waits for the console host to + // run itself down after its clients are gone, before reaping it. + conhostExitTimeout = time.Second + // hpconCloseTimeout bounds how long teardown waits for the background + // ClosePseudoConsole to return before leaving it to finish on its own. + hpconCloseTimeout = 2 * time.Second +) + +// pipeHandle owns one end of an anonymous pipe and serializes closing the +// handle against the I/O in flight on it. +// +// A blocked ReadFile is unblocked by the pipe breaking — the ConPTY side goes +// away once the pseudoconsole is closed and the console host exits — and not by +// closing the handle underneath the reader: Win32 does not define closing a +// handle that a synchronous ReadFile is parked on, and the freed handle value +// can be reused by the next CreatePipe in this process, which would leave a +// still-live read pointed at an unrelated pipe. So Close only marks the end +// closed and leaves the actual CloseHandle to whichever side finishes last. +type pipeHandle struct { + mu sync.Mutex + h windows.Handle + busy int + closed bool + freed bool +} + +func newPipeHandle(h windows.Handle) *pipeHandle { return &pipeHandle{h: h} } + +// acquire pins the handle for one syscall, or reports that the end is closed. +func (p *pipeHandle) acquire() (windows.Handle, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return 0, os.ErrClosed + } + p.busy++ + return p.h, nil +} + +func (p *pipeHandle) release() { + p.mu.Lock() + defer p.mu.Unlock() + p.busy-- + p.freeLocked() +} + +// freeLocked closes the handle once the end is closed and no syscall is holding +// it. Callers hold p.mu. +func (p *pipeHandle) freeLocked() { + if p.freed || !p.closed || p.busy > 0 { + return + } + p.freed = true + _ = windows.CloseHandle(p.h) +} + +// Close marks the end closed. It is idempotent and never blocks on in-flight +// I/O; see the type comment for who runs the CloseHandle. +func (p *pipeHandle) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + p.closed = true + p.freeLocked() + return nil +} + +// Read reads from the pipe with a raw ReadFile rather than wrapping the handle +// in an *os.File: os.NewFile hands the handle to the runtime poller, which does +// not fit a synchronous anonymous pipe, and reads past the pseudoconsole's +// opening handshake then stall indefinitely even while the child keeps writing. +// Owning the syscall also lets the pipe's end states map onto io.EOF exactly. +func (p *pipeHandle) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + h, err := p.acquire() + if err != nil { + // Reading a torn-down session is end of input, not a failure: the + // providers drain the session with io.Copy while closing it elsewhere. + return 0, io.EOF + } + defer p.release() + + var n uint32 + if err := windows.ReadFile(h, b, &n, nil); err != nil { + if isPipeEOF(err) { + return int(n), io.EOF + } + return int(n), err + } + if n == 0 { + // A zero-length read on a byte pipe only happens at end of input. + return 0, io.EOF + } + return int(n), nil +} + +// Write feeds the child's stdin, looping over short writes as io.Writer wants. +func (p *pipeHandle) Write(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + h, err := p.acquire() + if err != nil { + return 0, err + } + defer p.release() + + total := 0 + for total < len(b) { + var n uint32 + if err := windows.WriteFile(h, b[total:], &n, nil); err != nil { + if isPipeEOF(err) { + return total + int(n), io.ErrClosedPipe + } + return total + int(n), err + } + if n == 0 { + return total, io.ErrShortWrite + } + total += int(n) + } + return total, nil +} + +// isPipeEOF reports whether err means the far end of the pipe is gone, which is +// the normal way a ConPTY session ends: the console host closes its side when +// the pseudoconsole is closed after the child exits. +// +// The comparisons go through errors.Is rather than testing err directly. The +// callers hand over what ReadFile and WriteFile returned, unwrapped, so today +// the two are equivalent — but a syscall error that picks up any context on its +// way here would otherwise stop being recognised, and silently turn the end of a +// session into a read failure. +func isPipeEOF(err error) bool { + switch { + case errors.Is(err, windows.ERROR_BROKEN_PIPE), + errors.Is(err, windows.ERROR_PIPE_NOT_CONNECTED), + errors.Is(err, windows.ERROR_HANDLE_EOF): + return true + case errors.Is(err, windows.ERROR_OPERATION_ABORTED), + errors.Is(err, windows.ERROR_INVALID_HANDLE): + // The session was torn down underneath the call. + return true + } + return false +} + +// conPTY is a pseudoconsole plus the parent-side ends of the pipes wired to it: +// in feeds the child's stdin, out drains everything the console renders. +type conPTY struct { + hpc windows.Handle + // conhost is a handle to the console host serving this pseudoconsole, or 0 + // when it could not be identified; see openNewConhostChild. + conhost windows.Handle + + in *pipeHandle + out *pipeHandle + + hpcOnce sync.Once + hpcDone chan struct{} +} + +// newConPTY creates a pseudoconsole of the given size and returns the parent +// side of it. +func newConPTY(size windows.Coord) (*conPTY, error) { + var inRead, inWrite, outRead, outWrite windows.Handle + if err := windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil { + return nil, fmt.Errorf("CreatePipe (stdin): %w", err) + } + if err := windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil { + _ = windows.CloseHandle(inRead) + _ = windows.CloseHandle(inWrite) + return nil, fmt.Errorf("CreatePipe (stdout): %w", err) + } + + // CreatePseudoConsole also spawns the conhost.exe serving the console + // session, as a direct child of this process. Teardown wants a handle to it + // (see reapConhost) but Windows offers no way to derive one from the HPCON, + // so identify it by diffing our conhost children across the call, under a + // lock so that two sessions starting at once cannot make each other's diff + // ambiguous. The handle is opened right away, before the pid can be reused. + var hpc, conhost windows.Handle + conhostScanMu.Lock() + before := conhostChildren() + err := windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc) + if err == nil { + conhost = openNewConhostChild(before) + } + conhostScanMu.Unlock() + + // The pseudoconsole duplicates the child-side ends it needs, so drop ours + // either way: holding the output pipe's write end open here would keep the + // reader from ever seeing EOF. + _ = windows.CloseHandle(inRead) + _ = windows.CloseHandle(outWrite) + if err != nil { + _ = windows.CloseHandle(inWrite) + _ = windows.CloseHandle(outRead) + return nil, fmt.Errorf("CreatePseudoConsole: %w", err) + } + + return &conPTY{ + hpc: hpc, + conhost: conhost, + in: newPipeHandle(inWrite), + out: newPipeHandle(outRead), + hpcDone: make(chan struct{}), + }, nil +} + +// closeHPCON starts closing the pseudoconsole, exactly once, on its own +// goroutine, and returns a channel that is closed when that call returns. +// +// It must run off the caller's thread because ClosePseudoConsole can block for +// a long time: before Windows 11 24H2 it waits for the console host to exit, +// and closing only delivers CTRL_CLOSE_EVENT to the attached client rather than +// terminating it, so a client that keeps running keeps the host — and with it +// ClosePseudoConsole — alive arbitrarily long. Sequencing the job kill after a +// blocking close would deadlock in exactly the case the kill exists for, so +// teardown starts this and kills in parallel. +// +// Closing is also what ends the output stream: unlike a Unix master fd, which +// EOFs when the slave side closes on child exit, the ConPTY output pipe stays +// alive until the pseudoconsole is closed. The waiter therefore calls this as +// soon as the child exits, so a blocked Read sees EOF once the buffered output +// has drained. +func (c *conPTY) closeHPCON() <-chan struct{} { + c.hpcOnce.Do(func() { + go func() { + defer close(c.hpcDone) + windows.ClosePseudoConsole(c.hpc) + }() + }) + return c.hpcDone +} + +// reapConhost waits briefly for the console host to run itself down once its +// clients are gone, and terminates it if it does not. A healthy conhost exits by +// itself after the pseudoconsole is closed and its clients are gone, leaving +// this as just the handle close; conhost builds before Windows 11 24H2 can fail +// to complete that rundown when a client was killed after the close event was +// delivered — the fate of every client the job kill is for — and such a host +// then sits around forever serving nothing. +func (c *conPTY) reapConhost() { + if c.conhost == 0 { + return + } + event, err := windows.WaitForSingleObject(c.conhost, uint32(conhostExitTimeout/time.Millisecond)) + if err != nil || event != windows.WAIT_OBJECT_0 { + _ = windows.TerminateProcess(c.conhost, 1) + } + _ = windows.CloseHandle(c.conhost) + c.conhost = 0 +} + +// shutdown releases everything the pseudoconsole owns, running kill (when the +// caller has a child to reap) after the pseudoconsole close has been started but +// before anything waits on it: on builds where ClosePseudoConsole blocks until +// the console host exits, the host stays alive as long as a surviving client +// does, and that client only goes away through the kill — so ordering the kill +// after the close would deadlock in exactly the case the kill exists for. +// +// The pipe ends go first so that the pseudoconsole close, which flushes the +// client's pending output into the output pipe, cannot wedge on a pipe nobody is +// draining. A Read blocked at this moment is still draining and keeps its handle +// alive until it returns, which it does as soon as the far end goes away. +func (c *conPTY) shutdown(kill func()) { + _ = c.in.Close() + _ = c.out.Close() + done := c.closeHPCON() + + if kill != nil { + kill() + } + c.reapConhost() + + select { + case <-done: + case <-time.After(hpconCloseTimeout): + // Left to finish on its own. It only touches the pseudoconsole's own + // handles from here; both pipe ends are already released above. + } +} + +// teardown releases the pseudoconsole on the start error paths, where no child +// exists yet and so there is nothing to kill. +func (c *conPTY) teardown() { c.shutdown(nil) } + +// conhostScanMu serializes CreatePseudoConsole and the process scans around it, +// so concurrent starts cannot confuse each other's "which conhost is new" diff. +var conhostScanMu sync.Mutex + +// conhostChildren returns the pids of the conhost.exe processes that are direct +// children of this process. Errors just yield a smaller set: identifying the +// console host is best effort and never fails a start. +func conhostChildren() map[uint32]bool { + pids := map[uint32]bool{} + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return pids + } + defer func() { _ = windows.CloseHandle(snap) }() + + me := uint32(os.Getpid()) + var pe windows.ProcessEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + for err := windows.Process32First(snap, &pe); err == nil; err = windows.Process32Next(snap, &pe) { + if pe.ParentProcessID == me && strings.EqualFold(windows.UTF16ToString(pe.ExeFile[:]), "conhost.exe") { + pids[pe.ProcessID] = true + } + } + return pids +} + +// openNewConhostChild returns a handle to the single conhost child that appeared +// since the before scan, or 0 if there is not exactly one candidate or it cannot +// be opened. +func openNewConhostChild(before map[uint32]bool) windows.Handle { + var found []uint32 + for pid := range conhostChildren() { + if !before[pid] { + found = append(found, pid) + } + } + if len(found) != 1 { + return 0 + } + h, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, found[0]) + if err != nil { + return 0 + } + return h +} diff --git a/internal/terminal/jobobject_windows.go b/internal/terminal/jobobject_windows.go new file mode 100644 index 0000000..b53f7cb --- /dev/null +++ b/internal/terminal/jobobject_windows.go @@ -0,0 +1,92 @@ +//go:build windows + +package terminal + +import ( + "fmt" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// jobObject holds the ConPTY child and every process it goes on to spawn, so +// the session can terminate the whole tree at once. Closing the pseudoconsole +// only delivers CTRL_CLOSE_EVENT to the clients attached at that moment, which +// misses a child still starting up and any grandchild spawned while the console +// is going down, and clients are free to ignore it — the job kill is what +// actually reaps all of those. +// +// The handle is guarded because Kill (from the caller, or from the context +// watcher) and Close race for it: terminating through a handle that Close has +// already freed would either fail or, once the handle value is reused, act on an +// unrelated object. +type jobObject struct { + mu sync.Mutex + h windows.Handle + closed bool +} + +// newJobObject creates a job that kills its members when the last handle to it +// goes away. That doubles as a safety net: if the process exits without running +// a teardown, the handle is closed for us and the tree is still reaped. +func newJobObject() (*jobObject, error) { + h, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, fmt.Errorf("CreateJobObject: %w", err) + } + limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + } + if _, err := windows.SetInformationJobObject( + h, windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits)), + ); err != nil { + _ = windows.CloseHandle(h) + return nil, fmt.Errorf("SetInformationJobObject: %w", err) + } + return &jobObject{h: h}, nil +} + +// assign puts a process into the job. The child is created suspended so that +// this lands before it runs its first instruction, which is what guarantees +// every descendant it ever spawns is in the job from the start. +func (j *jobObject) assign(process windows.Handle) error { + j.mu.Lock() + defer j.mu.Unlock() + if j.closed { + return fmt.Errorf("AssignProcessToJobObject: job already closed") + } + if err := windows.AssignProcessToJobObject(j.h, process); err != nil { + return fmt.Errorf("AssignProcessToJobObject: %w", err) + } + return nil +} + +// terminate kills every process still in the job. It is safe to call when the +// tree is already gone (an empty job terminates successfully) and after close, +// which reports nothing left to do rather than touching a freed handle. +func (j *jobObject) terminate() error { + j.mu.Lock() + defer j.mu.Unlock() + if j.closed { + return nil + } + if err := windows.TerminateJobObject(j.h, 1); err != nil { + return fmt.Errorf("TerminateJobObject: %w", err) + } + return nil +} + +// close releases the job handle, which also kills anything still in it. +func (j *jobObject) close() error { + j.mu.Lock() + defer j.mu.Unlock() + if j.closed { + return nil + } + j.closed = true + return windows.CloseHandle(j.h) +} diff --git a/internal/terminal/session.go b/internal/terminal/session.go new file mode 100644 index 0000000..962424f --- /dev/null +++ b/internal/terminal/session.go @@ -0,0 +1,39 @@ +// Package terminal abstracts launching a child process attached to a +// pseudo-terminal, so provider triggers can drive an interactive CLI (Claude +// Code, Codex) without depending on a specific PTY backend. Unix/macOS uses a +// real PTY (github.com/creack/pty); Windows uses a ConPTY pseudoconsole, with +// the child and its descendants held in a job object so the whole tree can be +// torn down. +package terminal + +import ( + "context" + "io" +) + +// Session is a child process running under a pseudo-terminal. Read and Write +// are the PTY master side — Read drains the child's output, Write feeds its +// input. Close tears the PTY down, Wait blocks until the child exits, and Kill +// force-terminates it. The abstraction owns the whole process lifecycle (not +// just the I/O stream) because a PTY backend may spawn the child itself rather +// than through os/exec, so Wait and Kill cannot be assumed to come from an +// externally held *exec.Cmd. +type Session interface { + io.Reader + io.Writer + io.Closer + // Wait blocks until the child exits, returning its exit error (nil on a + // clean exit), matching (*exec.Cmd).Wait semantics. + Wait() error + // Kill force-terminates the child. It is safe to call when the process is + // already gone. + Kill() error +} + +// Start launches name (with args) attached to a new pseudo-terminal and returns +// a Session that owns the child's lifecycle. Cancelling ctx terminates the +// child. It returns an error and a nil Session if the terminal or the child +// cannot be created. +func Start(ctx context.Context, name string, args []string) (Session, error) { + return start(ctx, name, args) +} diff --git a/internal/terminal/session_unix.go b/internal/terminal/session_unix.go new file mode 100644 index 0000000..710037f --- /dev/null +++ b/internal/terminal/session_unix.go @@ -0,0 +1,52 @@ +//go:build !windows + +package terminal + +import ( + "context" + "errors" + "os" + "os/exec" + + "github.com/creack/pty" +) + +// unixSession wraps a creack/pty master file and the exec.Cmd running under it. +// This preserves the exact behavior the provider triggers relied on before the +// terminal seam was introduced: exec.CommandContext binds ctx cancellation to +// the child, pty.Start attaches the PTY, and the master file is the child's +// stdin/stdout. +type unixSession struct { + cmd *exec.Cmd + ptmx *os.File +} + +func start(ctx context.Context, name string, args []string) (Session, error) { + cmd := exec.CommandContext(ctx, name, args...) + ptmx, err := pty.Start(cmd) + if err != nil { + return nil, err + } + return &unixSession{cmd: cmd, ptmx: ptmx}, nil +} + +func (s *unixSession) Read(p []byte) (int, error) { return s.ptmx.Read(p) } +func (s *unixSession) Write(p []byte) (int, error) { return s.ptmx.Write(p) } +func (s *unixSession) Close() error { return s.ptmx.Close() } +func (s *unixSession) Wait() error { return s.cmd.Wait() } + +// Kill force-terminates the child. Once Wait has reaped it the process handle is +// marked done and Process.Kill reports os.ErrProcessDone, which is the state Kill +// is asked to reach, not a failure to reach it — the Session contract makes Kill +// safe on an already-gone child, and the Windows backend behaves that way because +// terminating a job whose members have exited succeeds. Every other error is a +// real failure to terminate and is returned. +func (s *unixSession) Kill() error { + if s.cmd.Process == nil { + return nil + } + if err := s.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + return nil +} diff --git a/internal/terminal/session_unix_test.go b/internal/terminal/session_unix_test.go new file mode 100644 index 0000000..c2738fc --- /dev/null +++ b/internal/terminal/session_unix_test.go @@ -0,0 +1,186 @@ +//go:build !windows + +package terminal + +import ( + "bytes" + "context" + "strings" + "sync" + "testing" + "time" +) + +// TestStartRunsCommandAndReadsOutput starts a trivial shell command under a real +// PTY, reads its output through the Session, and confirms Wait returns cleanly. +// It uses `sh -c echo` (always present on Unix), never a real CLI or credentials. +func TestStartRunsCommandAndReadsOutput(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + const marker = "pty-test-output" + sess, err := Start(ctx, "sh", []string{"-c", "echo " + marker}) + if err != nil { + t.Fatalf("Start: %v", err) + } + // Close the PTY session exactly once. The guard keeps the in-body close + // (which unblocks the reader) and the cleanup safety net mutually + // exclusive, so the session is never closed twice; the close error is + // reported at the in-body call rather than dropped. + closed := false + closeSession := func() error { + if closed { + return nil + } + closed = true + return sess.Close() + } + t.Cleanup(func() { + if err := closeSession(); err != nil { + t.Errorf("Close: %v", err) + } + }) + + // Drain the PTY master concurrently. Reading a master after the child exits + // can return EIO on Linux (vs a clean EOF on macOS), so the read error is + // intentionally ignored — the assertion is on the captured bytes. + var ( + mu sync.Mutex + buf bytes.Buffer + ) + readDone := make(chan struct{}) + go func() { + defer close(readDone) + b := make([]byte, 1024) + for { + n, rerr := sess.Read(b) + if n > 0 { + mu.Lock() + buf.Write(b[:n]) + mu.Unlock() + } + if rerr != nil { + return + } + } + }() + + waitErr := make(chan error, 1) + go func() { waitErr <- sess.Wait() }() + + select { + case err := <-waitErr: + if err != nil { + t.Fatalf("Wait returned an error for a clean exit: %v", err) + } + case <-ctx.Done(): + _ = sess.Kill() + t.Fatalf("timed out waiting for the command: %v", ctx.Err()) + } + + // Closing the master unblocks the reader if the child's exit didn't. + if err := closeSession(); err != nil { + t.Errorf("Close: %v", err) + } + select { + case <-readDone: + case <-time.After(2 * time.Second): + t.Fatal("reader did not finish after Close (possible hang)") + } + + mu.Lock() + got := buf.String() + mu.Unlock() + if !strings.Contains(got, marker) { + t.Fatalf("PTY output %q does not contain %q", got, marker) + } +} + +// TestKillTerminatesCommand confirms Kill stops a long-running child and that +// Wait then returns, all within the context deadline (no hang, no leaked child). +func TestKillTerminatesCommand(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sess, err := Start(ctx, "sh", []string{"-c", "sleep 60"}) + if err != nil { + t.Fatalf("Start: %v", err) + } + // Single, error-checked close (no second Close call anywhere in this test). + defer func() { + if err := sess.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }() + + // Keep the master drained so the child never blocks on a full PTY buffer. + go func() { + b := make([]byte, 512) + for { + if _, rerr := sess.Read(b); rerr != nil { + return + } + } + }() + + if err := sess.Kill(); err != nil { + t.Fatalf("Kill: %v", err) + } + + waitErr := make(chan error, 1) + go func() { waitErr <- sess.Wait() }() + select { + case <-waitErr: + // A killed process makes Wait return a non-nil error; either way it + // must return, which is what this test asserts. + case <-ctx.Done(): + t.Fatalf("Wait did not return after Kill (possible hang): %v", ctx.Err()) + } +} + +// TestKillAfterWaitSucceeds pins the Session contract's promise that Kill is +// safe once the child is already gone. After Wait reaps it, the underlying +// Process.Kill reports os.ErrProcessDone, which must not surface as a failure — +// the Windows backend returns nil in the same situation (TestSessionKill), and +// the two backends have to stay interchangeable for the provider triggers. +func TestKillAfterWaitSucceeds(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sess, err := Start(ctx, "sh", []string{"-c", "exit 0"}) + if err != nil { + t.Fatalf("Start: %v", err) + } + // Single, error-checked close (no second Close call anywhere in this test). + defer func() { + if err := sess.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }() + + // Keep the master drained so the child never blocks on a full PTY buffer. + go func() { + b := make([]byte, 512) + for { + if _, rerr := sess.Read(b); rerr != nil { + return + } + } + }() + + waitErr := make(chan error, 1) + go func() { waitErr <- sess.Wait() }() + select { + case err := <-waitErr: + if err != nil { + t.Fatalf("Wait returned an error for a clean exit: %v", err) + } + case <-ctx.Done(): + _ = sess.Kill() + t.Fatalf("Wait did not return for a command that exits immediately: %v", ctx.Err()) + } + + if err := sess.Kill(); err != nil { + t.Fatalf("Kill after Wait = %v, want nil for an already-exited child", err) + } +} diff --git a/internal/terminal/session_windows.go b/internal/terminal/session_windows.go new file mode 100644 index 0000000..cdc2e17 --- /dev/null +++ b/internal/terminal/session_windows.go @@ -0,0 +1,365 @@ +//go:build windows + +package terminal + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// windowsSession runs the child under a ConPTY pseudoconsole. Read and Write +// are the parent ends of the pipes wired to that console, and the child, plus +// anything it spawns, lives in a job object so the whole tree can be reaped. +type windowsSession struct { + pty *conPTY + job *jobObject + + // processDone is closed once the child has exited and waitErr holds its + // result. The real wait runs exactly once, in a background waiter started by + // start, so the process handle is released even if nobody ever calls Wait. + processDone chan struct{} + waitErr error + + closeOnce sync.Once +} + +func start(ctx context.Context, name string, args []string) (Session, error) { + // exec.Command is used only to resolve the executable and assemble argv the + // way os/exec would; the process itself has to be created by hand, because + // attaching a pseudoconsole needs an extended startup info block that + // (*exec.Cmd).Start does not expose. + cmd := exec.Command(name, args...) + if cmd.Err != nil { + return nil, cmd.Err + } + + pty, err := newConPTY(conPTYSize) + if err != nil { + return nil, err + } + job, err := newJobObject() + if err != nil { + pty.teardown() + return nil, err + } + proc, err := startAttached(cmd, pty.hpc, job) + if err != nil { + // Closing the job kills anything that did get assigned to it. + _ = job.close() + pty.teardown() + return nil, err + } + + s := &windowsSession{pty: pty, job: job, processDone: make(chan struct{})} + go s.wait(proc) + go s.watch(ctx) + return s, nil +} + +// startAttached creates the child attached to the pseudoconsole and returns its +// process handle, which the caller's waiter owns from then on. +// +// The startup info is an extended block whose Cb covers the whole STARTUPINFOEX +// (not just the STARTUPINFO inside it), carrying the pseudoconsole attribute, +// with EXTENDED_STARTUPINFO_PRESENT set so the attribute list is read at all. +// +// It also declares STARTF_USESTDHANDLES with all three handles left NULL, which +// is what actually attaches the child's stdio to the pseudoconsole rather than +// to ours. Without the flag, CreateProcess duplicates this process's standard +// handles into the child (it only skips that for CREATE_NEW_CONSOLE, +// CREATE_NO_WINDOW and DETACHED_PROCESS), and the console attach path replaces +// a standard handle "if the existing value is NULL, or ... looks like a console +// pseudohandle" — so inherited *console* handles get re-pointed at the +// pseudoconsole, while inherited pipes and files survive and the child renders +// into them instead. Every way this package is actually used has non-console +// standard handles: `bg`/`watch` run detached with their output on a log file, +// and the tests run under `go test`, which collects output through a pipe. The +// symptom is stark — the pseudoconsole emits its opening handshake and nothing +// more, while the child's output turns up on the parent's stdout. +// +// NULL handles rather than the pipe ends are what the console documentation +// asks for here (a console process started with no standard handles has them +// "filled automatically with appropriate handles to a new console", which for +// this child is the pseudoconsole), and it is what the Windows console +// maintainers recommend for a ConPTY parent whose own output is redirected. It +// is also why handle inheritance stays off: nothing is being handed down, and +// the pseudoconsole duplicates the pipe ends it needs by itself. +func startAttached(cmd *exec.Cmd, hpc windows.Handle, job *jobObject) (windows.Handle, error) { + attrs, err := windows.NewProcThreadAttributeList(1) + if err != nil { + return 0, fmt.Errorf("NewProcThreadAttributeList: %w", err) + } + defer attrs.Delete() + + // The attribute value is the HPCON itself, not a pointer to it — an HPCON is + // already a pointer-sized handle. Spelling that as unsafe.Pointer(hpc) would + // trip go vet's unsafeptr check (a uintptr-based type converted straight to + // unsafe.Pointer), so reinterpret the handle's bits through its address: + // &hpc is a real pointer, so none of these conversions is the flagged cast, + // and the resulting value is identical. + if err := attrs.Update( + windows.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + *(*unsafe.Pointer)(unsafe.Pointer(&hpc)), + unsafe.Sizeof(hpc), + ); err != nil { + return 0, fmt.Errorf("UpdateProcThreadAttribute: %w", err) + } + + var si windows.StartupInfoEx + si.Cb = uint32(unsafe.Sizeof(si)) + si.ProcThreadAttributeList = attrs.List() + si.Flags |= windows.STARTF_USESTDHANDLES + si.StdInput, si.StdOutput, si.StdErr = 0, 0, 0 + + app, line, err := prepareWindowsCommand(cmd) + if err != nil { + return 0, err + } + appName, err := windows.UTF16PtrFromString(app) + if err != nil { + return 0, err + } + cmdLine, err := windows.UTF16PtrFromString(line) + if err != nil { + return 0, err + } + + // A nil environment block inherits this process's environment, which is what + // the providers need (PATH, the CLI's own credentials in the user profile). + // CREATE_UNICODE_ENVIRONMENT documents that inherited block as Unicode. + var pi windows.ProcessInformation + if err := windows.CreateProcess( + appName, + cmdLine, + nil, // process security attributes + nil, // thread security attributes + false, // no handle inheritance; the pseudoconsole dups what it needs + windows.EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT|windows.CREATE_SUSPENDED, + nil, // environment: inherited + nil, // working directory: inherited + &si.StartupInfo, + &pi, + ); err != nil { + return 0, fmt.Errorf("CreateProcess: %w", err) + } + + // Created suspended so the job assignment lands before the child runs its + // first instruction; only then is every descendant it spawns in the job too. + if err := job.assign(pi.Process); err != nil { + _ = windows.TerminateProcess(pi.Process, 1) + _ = windows.CloseHandle(pi.Thread) + _ = windows.CloseHandle(pi.Process) + return 0, err + } + if _, err := windows.ResumeThread(pi.Thread); err != nil { + _ = windows.TerminateProcess(pi.Process, 1) + _ = windows.CloseHandle(pi.Thread) + _ = windows.CloseHandle(pi.Process) + return 0, fmt.Errorf("ResumeThread: %w", err) + } + _ = windows.CloseHandle(pi.Thread) + return pi.Process, nil +} + +// prepareWindowsCommand decides how CreateProcess should launch cmd: what to +// pass as lpApplicationName, and the command line to go with it. +// +// A native image is launched directly, with the arguments quoted the way the +// Windows CRT parses them back. A batch file is not an image, and CreateProcess +// documents the only supported way to run one: set lpApplicationName to the +// command interpreter and pass it /c plus the batch file. Some Windows builds do +// run a batch file handed straight to CreateProcess, which is why this went +// unnoticed, but nothing promises that and it is the wrong shape regardless — +// cmd.exe re-parses the command line under rules the CRT quoting does not +// account for, so an argument containing cmd metacharacters is not carried +// safely. Going through the interpreter explicitly makes the launch documented +// and lets the arguments be quoted for the parse that actually happens. +// +// This matters here because the npm-installed CLIs this package drives are .cmd +// shims on Windows, so the batch path is the common one, not the exotic one. +func prepareWindowsCommand(cmd *exec.Cmd) (appName, cmdLine string, err error) { + switch strings.ToLower(filepath.Ext(cmd.Path)) { + case ".cmd", ".bat": + return batchCommand(cmd.Path, cmd.Args[1:]) + default: + return cmd.Path, windows.ComposeCommandLine(cmd.Args), nil + } +} + +// batchCommand builds the interpreter invocation that runs a .cmd/.bat script. +// +// The command line is ` /d /v:off /s /c "