Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ jobs:
- name: Verify dependencies
run: go mod verify

# The PTY service is the one package whose behaviour is genuinely
# per-platform: creack/pty on Unix, ConPTY on Windows, and a termination
# path that has no SIGHUP to send on Windows at all. The Test job above
# only ever exercises the Linux half, so a ConPTY regression would reach
# a release having passed every gate. This runs the same tests `make
# test` runs, on each OS in the matrix.
- name: Smoke-test the PTY service on this platform
run: go test -race -shuffle=on -count=1 ./internal/pty/...

- name: Install the Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.13.0

Expand Down
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ go 1.26.5
// otherwise pull into this module's build, test, coverage and lint denominator.
ignore frontend/node_modules

require github.com/wailsapp/wails/v2 v2.13.0
require (
github.com/aymanbagabas/go-pty v0.2.3
github.com/wailsapp/wails/v2 v2.13.0
)

require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand All @@ -29,6 +33,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/u-root/u-root v0.16.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
Expand Down
18 changes: 18 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/aymanbagabas/go-pty v0.2.3 h1:hsqcTIUV8I4iTSh3HQl61CR2wh0YPS6gHOYLhAfWu/E=
github.com/aymanbagabas/go-pty v0.2.3/go.mod h1:GLkgQovzqN5A1xMB79yHWiG1rhcquZCjkwKQGKFPdPg=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
Expand All @@ -12,6 +16,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d h1:nP8SfQJqruIVSWYJTuYc37jLHEY1Z0fF+zKSrs3K/C8=
github.com/hugelgupf/vmtest v0.0.0-20240307030256-5d9f3d34a58d/go.mod h1:B63hDJMhTupLWCHwopAyEo7wRFowx9kOc8m8j1sfOqE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
Expand Down Expand Up @@ -51,6 +57,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7 h1:dtiVT4SeBUc/vHtwI2HjDZN+FCKTstQBxugIxJEGo9g=
github.com/u-root/gobusybox/src v0.0.0-20250101170133-2e884e4509c7/go.mod h1:PW3wGFCHjdHxAhra5FKvcARbCGqGfentYuPKmuhv8DY=
github.com/u-root/u-root v0.16.0 h1:wY40O83MBVks97+Is0WlFlOPSwKQMIrWP9R1IsrExg8=
github.com/u-root/u-root v0.16.0/go.mod h1:yL/XdSSW27PdGLgUh4MNRBy54mKM+TBLzpwiB4nwj90=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
Expand All @@ -63,9 +73,13 @@ github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v
github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand All @@ -75,9 +89,13 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
8 changes: 7 additions & 1 deletion godobject_budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ const (
// loose fields where one owner struct belonged. If raising it by more than
// one per service, the question to answer in review is why the service is
// not one handle.
maxAppFields = 1
//
// 1 -> 2 in #2: the PTY service arrives as a single *pty.Manager handle.
// Every terminal behavior — create, write, resize, kill, scrollback —
// lives in internal/pty and is reached through that one field, so this is
// the one-handle-per-service case the paragraph above describes, not
// state accumulating on the coordinator.
maxAppFields = 2

// maxAppMethods caps methods with an App receiver, counting value and
// pointer receivers alike. Pinned at today's actual with zero slack.
Expand Down
22 changes: 20 additions & 2 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
package app

import (
"context"
"embed"

"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"

"github.com/txn2/m6t/internal/buildinfo"
"github.com/txn2/m6t/internal/pty"
)

// Window geometry. m6t is a single-window app: the three-pane project
Expand All @@ -34,13 +36,19 @@ var windowBackground = &options.RGBA{R: 22, G: 24, B: 29, A: 1}
// asset server) out of it.
type App struct {
info buildinfo.Info

// terminals owns the PTY sessions behind the embedded terminal. It is one
// handle rather than loose state because the app composes services, it
// does not implement them: everything the terminal does lives in
// internal/pty and is reached through here.
terminals *pty.Manager
}

// newApp builds the binding. It is unexported because Options is the only
// supported way to construct the application: an App that is not bound into
// the window options is unreachable from the frontend.
func newApp() *App {
return &App{info: buildinfo.Get()}
return &App{info: buildinfo.Get(), terminals: pty.New()}
}

// Version reports the build identity to the frontend, which shows it in the
Expand All @@ -54,6 +62,8 @@ func (a *App) Version() buildinfo.Info {
// bound methods, embedded assets — is covered by tests rather than asserted in
// a comment.
func Options(assets embed.FS) *options.App {
application := newApp()

return &options.App{
Title: windowTitle,
Width: windowWidth,
Expand All @@ -62,6 +72,14 @@ func Options(assets embed.FS) *options.App {
MinHeight: windowMinHeight,
AssetServer: &assetserver.Options{Assets: assets},
BackgroundColour: windowBackground,
Bind: []any{newApp()},
Bind: []any{application},

// PTYs are backend-owned and outlive every window in the app, so the
// only thing that ends them is the app ending. Without this hook,
// quitting m6t would leave the user's shells — and whatever they were
// running — orphaned behind it.
OnShutdown: func(context.Context) {
application.terminals.Shutdown()
},
}
}
38 changes: 38 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package app

import (
"context"
"embed"
"errors"
"runtime"
"testing"

"github.com/txn2/m6t/internal/buildinfo"
"github.com/txn2/m6t/internal/pty"
)

//go:embed testdata
Expand Down Expand Up @@ -75,6 +79,40 @@ func TestOptionsBindOnlyTheApp(t *testing.T) {
}
}

// PTYs are backend-owned and outlive every window, so the app quitting is the
// only thing that ends them. Without the shutdown hook, closing m6t would
// orphan the user's shells and whatever they were running.
func TestShutdownTerminatesEveryTerminalSession(t *testing.T) {
opts := Options(testAssets)

application, ok := opts.Bind[0].(*App)
if !ok {
t.Fatalf("Bind[0] is %T, want *App", opts.Bind[0])
}
if opts.OnShutdown == nil {
t.Fatal("OnShutdown is nil; quitting the app would leave its PTY sessions running")
}

id, err := application.terminals.Create(pty.Options{Command: longRunningCommand()})
if err != nil {
t.Fatalf("creating a terminal session: %v", err)
}

opts.OnShutdown(context.Background())

if _, err := application.terminals.Attach(id); !errors.Is(err, pty.ErrNoSuchSession) {
t.Errorf("session %s survived shutdown: Attach error = %v, want ErrNoSuchSession", id, err)
}
}

// longRunningCommand returns an argv that stays alive until it is killed.
func longRunningCommand() []string {
if runtime.GOOS == "windows" {
return []string{"cmd.exe", "/c", "ping -n 61 127.0.0.1 >NUL"}
}
return []string{"/bin/sh", "-c", "sleep 60"}
}

func TestEachCallBuildsItsOwnBinding(t *testing.T) {
first := Options(testAssets).Bind[0]
second := Options(testAssets).Bind[0]
Expand Down
42 changes: 42 additions & 0 deletions internal/pty/hangup_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//go:build !windows

package pty

import (
"fmt"
"os"
"syscall"
)

// hangup asks the child to end the way closing a terminal window does, giving
// a shell the chance to run its exit traps.
func hangup(proc *os.Process) error {
return signalGroup(proc, syscall.SIGHUP)
}

// forceKill ends the child unconditionally. It is the follow-up for a child
// that ignored the hangup.
func forceKill(proc *os.Process) error {
return signalGroup(proc, syscall.SIGKILL)
}

// signalGroup signals the child's whole process group, falling back to the
// child alone if the group cannot be determined.
//
// The group is the point. go-pty starts the child in its own session, so a
// shell that has spawned `claude` or `vim` is the leader of a group containing
// them. Signaling only the shell would reap the shell and orphan everything
// it started — which is exactly the "no zombie processes" failure this is
// written to avoid.
func signalGroup(proc *os.Process, sig syscall.Signal) error {
if pgid, err := syscall.Getpgid(proc.Pid); err == nil {
if err := syscall.Kill(-pgid, sig); err != nil {
return fmt.Errorf("signaling process group %d: %w", pgid, err)
}
return nil
}
if err := proc.Signal(sig); err != nil {
return fmt.Errorf("signaling process %d: %w", proc.Pid, err)
}
return nil
}
28 changes: 28 additions & 0 deletions internal/pty/hangup_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build windows

package pty

import (
"fmt"
"os"
)

// hangup ends the child.
//
// Windows has no SIGHUP. A console application is ended by closing its
// pseudoconsole or by terminating the process outright, and go-pty owns the
// pseudoconsole handle, so termination is the honest option here. The
// consequence is that the killGrace window in session.kill buys a Windows
// child nothing — it is already gone when the grace period would have started.
func hangup(proc *os.Process) error {
if err := proc.Kill(); err != nil {
return fmt.Errorf("terminating process %d: %w", proc.Pid, err)
}
return nil
}

// forceKill ends the child unconditionally. On Windows there is nothing
// gentler than hangup to escalate from, so the two are the same call.
func forceKill(proc *os.Process) error {
return hangup(proc)
}
Loading
Loading