Persistent daemon + thin networked client #9
@@ -7,6 +7,12 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- `patterm daemon`, `patterm daemon stop`, and `patterm ls` now expose
|
||||
a local unix-socket daemon lifecycle for the daemon/client split.
|
||||
- The local daemon protocol now supports attach, explicit detach,
|
||||
project listing, focused-pane snapshots, pane chunks, resize/focus
|
||||
updates, and daemon-owned command spawn requests while keeping child
|
||||
processes alive after a client disconnects.
|
||||
- patterm can now keep multiple local projects loaded in one loopback
|
||||
daemon core, with command-palette entries to switch the current
|
||||
client view or open another project without tearing down processes
|
||||
@@ -25,6 +31,8 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
over MCP.
|
||||
|
||||
### Fixed
|
||||
- MCP scratchpad tools now route through the caller's project instead
|
||||
of always using the daemon registry's default project.
|
||||
- Injected agent input now sends the submit Enter as a separated,
|
||||
settled keystroke so messages reliably submit instead of sometimes
|
||||
sitting unsent in the composer.
|
||||
|
||||
@@ -14,7 +14,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -27,6 +29,7 @@ import (
|
||||
"github.com/hjbdev/patterm/internal/app"
|
||||
"github.com/hjbdev/patterm/internal/mcp"
|
||||
"github.com/hjbdev/patterm/internal/projectkey"
|
||||
"github.com/hjbdev/patterm/internal/protocol"
|
||||
)
|
||||
|
||||
// version is overridden at build time via `-ldflags "-X main.version=..."`.
|
||||
@@ -48,6 +51,15 @@ func main() {
|
||||
runDebugHarness()
|
||||
return
|
||||
}
|
||||
if len(os.Args) >= 2 && os.Args[1] == "daemon" {
|
||||
os.Args = append(os.Args[:1], os.Args[2:]...)
|
||||
runDaemonCommand()
|
||||
return
|
||||
}
|
||||
if len(os.Args) >= 2 && os.Args[1] == "ls" {
|
||||
runDaemonList()
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
projectDir = flag.String("project", "", "project directory (default $PWD)")
|
||||
@@ -194,6 +206,78 @@ func runMCPProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
func runDaemonCommand() {
|
||||
if len(os.Args) >= 2 && os.Args[1] == "stop" {
|
||||
runDaemonStop()
|
||||
return
|
||||
}
|
||||
if len(os.Args) >= 2 && os.Args[1] == "ls" {
|
||||
runDaemonList()
|
||||
return
|
||||
}
|
||||
var projectDir = flag.String("project", "", "initial project directory (default $PWD)")
|
||||
flag.Parse()
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
die("getwd: %v", err)
|
||||
}
|
||||
if *projectDir != "" {
|
||||
cwd = *projectDir
|
||||
}
|
||||
if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil {
|
||||
die("daemon: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runDaemonList() {
|
||||
projects, err := daemonRequest(protocol.Frame{Type: protocol.FrameList})
|
||||
if err != nil {
|
||||
die("ls: %v", err)
|
||||
}
|
||||
for _, p := range projects.Projects {
|
||||
fmt.Printf("%s\t%d\t%s\n", p.Key, p.TabCount, p.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func runDaemonStop() {
|
||||
if _, err := daemonRequest(protocol.Frame{Type: protocol.FrameStop}); err != nil {
|
||||
die("daemon stop: %v", err)
|
||||
}
|
||||
fmt.Println("stopped")
|
||||
}
|
||||
|
||||
func daemonRequest(req protocol.Frame) (protocol.ProjectList, error) {
|
||||
socket, _, err := app.RuntimeDaemonPaths()
|
||||
if err != nil {
|
||||
return protocol.ProjectList{}, err
|
||||
}
|
||||
conn, err := net.Dial("unix", socket)
|
||||
if err != nil {
|
||||
return protocol.ProjectList{}, err
|
||||
}
|
||||
defer conn.Close()
|
||||
t := protocol.NewConnTransport(conn)
|
||||
if err := t.Send(req); err != nil {
|
||||
return protocol.ProjectList{}, err
|
||||
}
|
||||
resp, err := t.Recv()
|
||||
if err != nil {
|
||||
return protocol.ProjectList{}, err
|
||||
}
|
||||
if resp.Type == protocol.FrameError {
|
||||
var msg protocol.Error
|
||||
_ = json.Unmarshal(resp.Payload, &msg)
|
||||
if msg.Message == "" {
|
||||
msg.Message = "daemon returned an error"
|
||||
}
|
||||
return protocol.ProjectList{}, fmt.Errorf("%s", msg.Message)
|
||||
}
|
||||
if resp.Type != protocol.FrameProjectList {
|
||||
return protocol.ProjectList{}, fmt.Errorf("unexpected daemon response %q", resp.Type)
|
||||
}
|
||||
return protocol.Decode[protocol.ProjectList](resp)
|
||||
}
|
||||
|
||||
func versionString() string {
|
||||
commit, date := "unknown", "unknown"
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
|
||||
375
internal/app/daemon_net.go
Normal file
375
internal/app/daemon_net.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/mcp"
|
||||
"github.com/hjbdev/patterm/internal/preset"
|
||||
"github.com/hjbdev/patterm/internal/protocol"
|
||||
)
|
||||
|
||||
type DaemonOptions struct {
|
||||
ProjectDir string
|
||||
SocketPath string
|
||||
PidPath string
|
||||
Cols uint16
|
||||
Rows uint16
|
||||
}
|
||||
|
||||
type DaemonStatus struct {
|
||||
PID int
|
||||
Socket string
|
||||
Projects []protocol.Project
|
||||
}
|
||||
|
||||
func RuntimeDaemonPaths() (socketPath, pidPath string, err error) {
|
||||
base := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if base == "" {
|
||||
base = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(base, "patterm")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return filepath.Join(dir, "daemon.sock"), filepath.Join(dir, "daemon.pid"), nil
|
||||
}
|
||||
|
||||
func RunDaemon(ctx context.Context, opts DaemonOptions) error {
|
||||
if opts.ProjectDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.ProjectDir = cwd
|
||||
}
|
||||
if opts.SocketPath == "" || opts.PidPath == "" {
|
||||
socket, pid, err := RuntimeDaemonPaths()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.SocketPath == "" {
|
||||
opts.SocketPath = socket
|
||||
}
|
||||
if opts.PidPath == "" {
|
||||
opts.PidPath = pid
|
||||
}
|
||||
}
|
||||
if opts.Cols == 0 {
|
||||
opts.Cols = 80
|
||||
}
|
||||
if opts.Rows == 0 {
|
||||
opts.Rows = 24
|
||||
}
|
||||
lockPath, err := prepareDaemonSocket(opts.SocketPath, opts.PidPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(lockPath)
|
||||
ln, err := net.Listen("unix", opts.SocketPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: listen %s: %w", opts.SocketPath, err)
|
||||
}
|
||||
defer ln.Close()
|
||||
defer os.Remove(opts.SocketPath)
|
||||
if err := os.Chmod(opts.SocketPath, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(opts.PidPath, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(opts.PidPath)
|
||||
|
||||
presets, err := preset.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: load presets: %w", err)
|
||||
}
|
||||
appSettings, _, err := loadSettings()
|
||||
if err != nil {
|
||||
logf("daemon settings load: %v", err)
|
||||
}
|
||||
mcpSrv, err := mcp.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: mcp start: %w", err)
|
||||
}
|
||||
defer mcpSrv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
registry := newProjectRegistry(presets, appSettings, mcpSrv, opts.Cols, opts.Rows)
|
||||
defer registry.Shutdown()
|
||||
mcpSrv.SetHost(registry)
|
||||
if _, err := registry.Open(ctx, opts.ProjectDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = ln.Close()
|
||||
}()
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
handleDaemonConn(ctx, cancel, registry, protocol.NewConnTransport(conn))
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func prepareDaemonSocket(socketPath, pidPath string) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
lockPath := pidPath + ".lock"
|
||||
if data, err := os.ReadFile(pidPath); err == nil {
|
||||
if pid, perr := strconv.Atoi(strings.TrimSpace(string(data))); perr == nil && pid > 0 {
|
||||
if sigErr := syscallSignal0(pid); sigErr == nil {
|
||||
return "", fmt.Errorf("daemon already running with pid %d", pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = os.Remove(socketPath)
|
||||
_ = os.Remove(pidPath)
|
||||
_ = os.Remove(lockPath)
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("daemon: lock %s: %w", lockPath, err)
|
||||
}
|
||||
_, _ = f.WriteString(strconv.Itoa(os.Getpid()) + "\n")
|
||||
_ = f.Close()
|
||||
return lockPath, nil
|
||||
}
|
||||
|
||||
func syscallSignal0(pid int) error {
|
||||
return syscall.Kill(pid, 0)
|
||||
}
|
||||
|
||||
func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistry, t protocol.Transport) {
|
||||
defer t.Close()
|
||||
f, err := t.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch f.Type {
|
||||
case protocol.FrameList:
|
||||
_ = sendProjectList(t, registry, "")
|
||||
return
|
||||
case protocol.FrameStop:
|
||||
_ = sendProjectList(t, registry, "")
|
||||
stop()
|
||||
return
|
||||
case protocol.FrameAttach:
|
||||
handleDaemonAttach(ctx, registry, t, f)
|
||||
default:
|
||||
_ = sendProtocolError(t, fmt.Sprintf("first frame must be attach, list, or stop; got %q", f.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protocol.Transport, first protocol.Frame) {
|
||||
attach, err := protocol.Decode[protocol.Attach](first)
|
||||
if err != nil {
|
||||
_ = sendProtocolError(t, err.Error())
|
||||
return
|
||||
}
|
||||
project := registry.Project(attach.ProjectKey)
|
||||
if project == nil && attach.ProjectPath != "" {
|
||||
project, err = registry.Open(ctx, attach.ProjectPath)
|
||||
if err != nil {
|
||||
_ = sendProtocolError(t, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if project == nil {
|
||||
project = registry.DefaultProject()
|
||||
}
|
||||
if project == nil {
|
||||
_ = sendProtocolError(t, "no project open")
|
||||
return
|
||||
}
|
||||
if attach.TermSize.Cols > 0 && attach.TermSize.Rows > 0 {
|
||||
project.Session.ResizeAll(attach.TermSize.Cols, attach.TermSize.Rows)
|
||||
project.Launcher.SetSize(attach.TermSize.Cols, attach.TermSize.Rows)
|
||||
project.Host.SetSize(attach.TermSize.Cols, attach.TermSize.Rows)
|
||||
}
|
||||
|
||||
view := ClientView{
|
||||
ID: fmt.Sprintf("c-%d", time.Now().UnixNano()),
|
||||
ProjectKey: project.Key,
|
||||
ProjectName: project.Name,
|
||||
Cols: attach.TermSize.Cols,
|
||||
Rows: attach.TermSize.Rows,
|
||||
}
|
||||
if child := firstRunningTopLevel(project.Session.Children()); child != nil {
|
||||
view.FocusChild(child.ID)
|
||||
}
|
||||
sub := newClientSubscriber(project.Key, defaultClientSubscriberQueue)
|
||||
project.Session.SubscribeClient(sub)
|
||||
defer project.Session.UnsubscribeClient(sub)
|
||||
|
||||
_ = sendHello(t, project, view.ID)
|
||||
_ = sendProjectList(t, registry, project.Key)
|
||||
_ = sendChrome(t, project, view)
|
||||
if view.FocusedID != "" {
|
||||
_ = sendSnapshot(t, project, view.FocusedID)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
f, ok := sub.Recv()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := t.Send(f); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
f, err := t.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch f.Type {
|
||||
case protocol.FrameDetach:
|
||||
return
|
||||
case protocol.FrameInput:
|
||||
msg, err := protocol.Decode[protocol.Input](f)
|
||||
if err == nil {
|
||||
if c := project.Session.FindChild(msg.PaneID); c != nil {
|
||||
_ = c.InjectAsUser(msg.Bytes)
|
||||
}
|
||||
}
|
||||
case protocol.FrameResize:
|
||||
msg, err := protocol.Decode[protocol.Resize](f)
|
||||
if err == nil {
|
||||
project.Session.ResizeAll(msg.Size.Cols, msg.Size.Rows)
|
||||
project.Launcher.SetSize(msg.Size.Cols, msg.Size.Rows)
|
||||
project.Host.SetSize(msg.Size.Cols, msg.Size.Rows)
|
||||
}
|
||||
case protocol.FrameFocus:
|
||||
msg, err := protocol.Decode[protocol.Focus](f)
|
||||
if err == nil && msg.PaneID != "" {
|
||||
view.FocusChild(msg.PaneID)
|
||||
_ = sendChrome(t, project, view)
|
||||
_ = sendSnapshot(t, project, msg.PaneID)
|
||||
}
|
||||
case protocol.FramePaletteCommand:
|
||||
if child := handleDaemonPaletteCommand(project, f); child != nil {
|
||||
view.FocusChild(child.ID)
|
||||
_ = sendChrome(t, project, view)
|
||||
_ = sendSnapshot(t, project, child.ID)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleDaemonPaletteCommand(project *Project, f protocol.Frame) *Child {
|
||||
msg, err := protocol.Decode[protocol.PaletteCommand](f)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch msg.Kind {
|
||||
case "spawn_command":
|
||||
var p struct {
|
||||
Argv []string `json:"argv"`
|
||||
Name string `json:"name"`
|
||||
WorkDir string `json:"working_dir"`
|
||||
Shell bool `json:"shell"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &p); err != nil || len(p.Argv) == 0 {
|
||||
return nil
|
||||
}
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = strings.Join(p.Argv, " ")
|
||||
}
|
||||
c, err := project.Launcher.LaunchCommandArgv(p.Argv, name, "", p.WorkDir, nil, p.Shell)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendHello(t protocol.Transport, p *Project, clientID string) error {
|
||||
f, err := protocol.NewFrame(protocol.FrameHello, protocol.Hello{Version: 1, DaemonID: strconv.Itoa(os.Getpid()), ClientID: clientID, ProjectKey: p.Key})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Send(f)
|
||||
}
|
||||
|
||||
func sendProjectList(t protocol.Transport, registry *ProjectRegistry, current string) error {
|
||||
summaries := registry.Summaries(current)
|
||||
projects := make([]protocol.Project, 0, len(summaries))
|
||||
for _, p := range summaries {
|
||||
projects = append(projects, protocol.Project{Key: p.Key, Path: p.Dir, Name: p.Name, TabCount: p.TabCount})
|
||||
}
|
||||
f, err := protocol.NewFrame(protocol.FrameProjectList, protocol.ProjectList{Projects: projects})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Send(f)
|
||||
}
|
||||
|
||||
func sendChrome(t protocol.Transport, p *Project, view ClientView) error {
|
||||
pads, _ := p.Pads.List()
|
||||
model := buildChromeModel(p.Key, view, p.Session.Children(), pads)
|
||||
b, err := json.Marshal(model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := protocol.NewFrame(protocol.FrameChrome, protocol.Chrome{ProjectKey: p.Key, Model: b})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Send(f)
|
||||
}
|
||||
|
||||
func sendSnapshot(t protocol.Transport, p *Project, paneID string) error {
|
||||
b, err := p.Session.SerializeChild(paneID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
f, err := protocol.NewFrame(protocol.FramePaneSnapshot, protocol.PaneSnapshot{PaneID: paneID, Bytes: b})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Send(f)
|
||||
}
|
||||
|
||||
func sendProtocolError(t protocol.Transport, msg string) error {
|
||||
f, err := protocol.NewFrame(protocol.FrameError, protocol.Error{Message: msg})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return t.Send(f)
|
||||
}
|
||||
213
internal/app/daemon_net_test.go
Normal file
213
internal/app/daemon_net_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/protocol"
|
||||
)
|
||||
|
||||
func TestDaemonDetachReattachPreservesProcess(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
|
||||
t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data"))
|
||||
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
|
||||
projectDir := filepath.Join(root, "project")
|
||||
if err := os.MkdirAll(projectDir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir project: %v", err)
|
||||
}
|
||||
socket := filepath.Join(root, "runtime", "patterm", "daemon.sock")
|
||||
pid := filepath.Join(root, "runtime", "patterm", "daemon.pid")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- RunDaemon(ctx, DaemonOptions{
|
||||
ProjectDir: projectDir,
|
||||
SocketPath: socket,
|
||||
PidPath: pid,
|
||||
Cols: 80,
|
||||
Rows: 24,
|
||||
})
|
||||
}()
|
||||
waitForSocket(t, socket, errCh)
|
||||
|
||||
client1 := dialDaemon(t, socket)
|
||||
sendFrame(t, client1, protocol.FrameAttach, protocol.Attach{
|
||||
ProjectPath: projectDir,
|
||||
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
||||
})
|
||||
expectFrame(t, client1, protocol.FrameHello)
|
||||
expectFrame(t, client1, protocol.FrameProjectList)
|
||||
expectFrame(t, client1, protocol.FrameChrome)
|
||||
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"argv": []string{"sh", "-c", "trap 'exit 0' TERM; while :; do echo STILL-HERE; sleep 1; done"},
|
||||
"name": "survivor",
|
||||
})
|
||||
sendFrame(t, client1, protocol.FramePaletteCommand, protocol.PaletteCommand{
|
||||
Kind: "spawn_command",
|
||||
Data: data,
|
||||
})
|
||||
waitForLifecycle(t, client1, protocol.LifecycleSpawned, 3*time.Second)
|
||||
sendFrame(t, client1, protocol.FrameDetach, protocol.Detach{})
|
||||
_ = client1.Close()
|
||||
|
||||
client2 := dialDaemon(t, socket)
|
||||
defer client2.Close()
|
||||
sendFrame(t, client2, protocol.FrameAttach, protocol.Attach{
|
||||
ProjectPath: projectDir,
|
||||
TermSize: protocol.Size{Cols: 80, Rows: 24},
|
||||
})
|
||||
expectFrame(t, client2, protocol.FrameHello)
|
||||
expectFrame(t, client2, protocol.FrameProjectList)
|
||||
chrome := expectChrome(t, client2)
|
||||
if !chromeHasProcess(chrome, "survivor") {
|
||||
t.Fatalf("reattached chrome did not include surviving process: %s", string(chrome.Model))
|
||||
}
|
||||
expectFrame(t, client2, protocol.FramePaneSnapshot)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("daemon returned error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("daemon did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForSocket(t *testing.T, socket string, errCh <-chan error) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(socket); err == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil && strings.Contains(err.Error(), "operation not permitted") {
|
||||
t.Skipf("unix sockets unavailable in this sandbox: %v", err)
|
||||
}
|
||||
t.Fatalf("daemon exited before creating socket: %v", err)
|
||||
default:
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("socket %s was not created", socket)
|
||||
}
|
||||
|
||||
func dialDaemon(t *testing.T, socket string) protocol.Transport {
|
||||
t.Helper()
|
||||
conn, err := net.Dial("unix", socket)
|
||||
if err != nil {
|
||||
t.Fatalf("dial daemon: %v", err)
|
||||
}
|
||||
return protocol.NewConnTransport(conn)
|
||||
}
|
||||
|
||||
func sendFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
|
||||
t.Helper()
|
||||
f, err := protocol.NewFrame(typ, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("frame %s: %v", typ, err)
|
||||
}
|
||||
if err := tr.Send(f); err != nil {
|
||||
t.Fatalf("send %s: %v", typ, err)
|
||||
}
|
||||
}
|
||||
|
||||
func expectFrame(t *testing.T, tr protocol.Transport, typ protocol.FrameType) protocol.Frame {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("recv %s: %v", typ, err)
|
||||
}
|
||||
if f.Type == typ {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("frame %s not received", typ)
|
||||
return protocol.Frame{}
|
||||
}
|
||||
|
||||
func expectChrome(t *testing.T, tr protocol.Transport) protocol.Chrome {
|
||||
t.Helper()
|
||||
f := expectFrame(t, tr, protocol.FrameChrome)
|
||||
chrome, err := protocol.Decode[protocol.Chrome](f)
|
||||
if err != nil {
|
||||
t.Fatalf("decode chrome: %v", err)
|
||||
}
|
||||
return chrome
|
||||
}
|
||||
|
||||
func waitForLifecycle(t *testing.T, tr protocol.Transport, kind protocol.LifecycleKind, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
f, err, ok := recvFrameWithin(tr, time.Until(deadline))
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("recv lifecycle: %v", err)
|
||||
}
|
||||
if f.Type != protocol.FrameLifecycle {
|
||||
continue
|
||||
}
|
||||
msg, err := protocol.Decode[protocol.Lifecycle](f)
|
||||
if err != nil {
|
||||
t.Fatalf("decode lifecycle: %v", err)
|
||||
}
|
||||
if msg.Kind == kind {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("lifecycle %s not received", kind)
|
||||
}
|
||||
|
||||
func recvFrameWithin(tr protocol.Transport, timeout time.Duration) (protocol.Frame, error, bool) {
|
||||
type result struct {
|
||||
f protocol.Frame
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
f, err := tr.Recv()
|
||||
ch <- result{f: f, err: err}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
return r.f, r.err, true
|
||||
case <-time.After(timeout):
|
||||
return protocol.Frame{}, nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func chromeHasProcess(chrome protocol.Chrome, name string) bool {
|
||||
var model struct {
|
||||
Processes []childModel `json:"processes"`
|
||||
}
|
||||
if err := json.Unmarshal(chrome.Model, &model); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, p := range model.Processes {
|
||||
if p.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -153,6 +153,24 @@ func (s *Session) Unsubscribe(l ChildEventListener) {
|
||||
s.listeners.Store(&next)
|
||||
}
|
||||
|
||||
// UnsubscribeClient removes a previously-registered network client listener.
|
||||
// Safe to call with a listener that was never registered.
|
||||
func (s *Session) UnsubscribeClient(l ChildEventListener) {
|
||||
s.clientListenersMu.Lock()
|
||||
defer s.clientListenersMu.Unlock()
|
||||
prev := s.clientListenersSnapshot()
|
||||
if len(prev) == 0 {
|
||||
return
|
||||
}
|
||||
next := make([]ChildEventListener, 0, len(prev))
|
||||
for _, e := range prev {
|
||||
if e != l {
|
||||
next = append(next, e)
|
||||
}
|
||||
}
|
||||
s.clientListeners.Store(&next)
|
||||
}
|
||||
|
||||
// listenersSnapshot returns the frozen listener slice. Safe to call
|
||||
// without the listeners mutex.
|
||||
func (s *Session) listenersSnapshot() []ChildEventListener {
|
||||
|
||||
@@ -32,6 +32,9 @@ const (
|
||||
FramePaletteCommand FrameType = "palette_command"
|
||||
FrameTrustResponse FrameType = "trust_response"
|
||||
FrameResize FrameType = "resize"
|
||||
FrameList FrameType = "list"
|
||||
FrameStop FrameType = "stop"
|
||||
FrameError FrameType = "error"
|
||||
)
|
||||
|
||||
// Frame is the transport envelope. Payload is deliberately raw JSON so
|
||||
@@ -72,9 +75,10 @@ type Hello struct {
|
||||
}
|
||||
|
||||
type Attach struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
ProjectKey string `json:"project_key,omitempty"`
|
||||
TermSize Size `json:"term_size"`
|
||||
Token string `json:"token,omitempty"`
|
||||
ProjectKey string `json:"project_key,omitempty"`
|
||||
ProjectPath string `json:"project_path,omitempty"`
|
||||
TermSize Size `json:"term_size"`
|
||||
}
|
||||
|
||||
type Detach struct {
|
||||
@@ -162,3 +166,7 @@ type TrustResponse struct {
|
||||
type Resize struct {
|
||||
Size Size `json:"size"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user