From 5149224000372b234ef70f767cfb1aafcb52d791 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 14:09:51 +0100 Subject: [PATCH] attach default client to local daemon --- CHANGELOG.md | 4 + cmd/patterm/main.go | 28 +- internal/app/client_net.go | 637 +++++++++++++++++++++++ internal/app/client_net_test.go | 157 ++++++ internal/harness/restart_persist_test.go | 2 +- internal/harness/session.go | 2 +- 6 files changed, 824 insertions(+), 6 deletions(-) create mode 100644 internal/app/client_net.go create mode 100644 internal/app/client_net_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 16835bf..5c574ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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. +- The default `patterm [dir]` startup now auto-starts the local daemon + on demand and attaches a thin terminal client over the unix-socket + transport; `--in-process` or `PATTERM_NO_DAEMON=1` keeps the legacy + single-process path available as an escape hatch. - 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 diff --git a/cmd/patterm/main.go b/cmd/patterm/main.go index b76ae57..82e4270 100644 --- a/cmd/patterm/main.go +++ b/cmd/patterm/main.go @@ -64,6 +64,7 @@ func main() { var ( projectDir = flag.String("project", "", "project directory (default $PWD)") showVersion = flag.Bool("version", false, "print version and exit") + inProcess = flag.Bool("in-process", false, "run the legacy single-process TUI instead of attaching to the daemon") debugDir = flag.String("debug", "", "write debug logs + per-child raw PTY output to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/debug when DIR is omitted)") profileDir = flag.String("profile", "", "write pprof files (cpu/heap/goroutine) and live perf counters (metrics.jsonl per-second, metrics.json + summary.txt on exit) to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/profile when DIR is omitted)") ) @@ -84,6 +85,8 @@ func main() { } if *projectDir != "" { cwd = *projectDir + } else if flag.NArg() > 0 { + cwd = flag.Arg(0) } key, err := projectkey.Key(cwd) if err != nil { @@ -107,11 +110,26 @@ func main() { defer stopProfile() ctx := context.Background() - if err := app.Run(ctx, app.Options{ + if *inProcess || os.Getenv("PATTERM_NO_DAEMON") != "" { + if err := app.Run(ctx, app.Options{ + ProjectDir: cwd, + ProjectKey: key, + DebugDir: resolvedDebug, + ProfileDir: resolvedProfile, + }); err != nil { + die("%v", err) + } + return + } + if resolvedDebug != "" || resolvedProfile != "" { + die("--debug and --profile currently require --in-process") + } + if err := app.RunAttachedClient(ctx, app.ClientOptions{ ProjectDir: cwd, - ProjectKey: key, - DebugDir: resolvedDebug, - ProfileDir: resolvedProfile, + Stdin: os.Stdin, + Stdout: os.Stdout, + RawMode: true, + AutoStart: true, }); err != nil { die("%v", err) } @@ -223,6 +241,8 @@ func runDaemonCommand() { } if *projectDir != "" { cwd = *projectDir + } else if flag.NArg() > 0 { + cwd = flag.Arg(0) } if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil { die("daemon: %v", err) diff --git a/internal/app/client_net.go b/internal/app/client_net.go new file mode 100644 index 0000000..9169477 --- /dev/null +++ b/internal/app/client_net.go @@ -0,0 +1,637 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + cpty "github.com/creack/pty" + "golang.org/x/term" + + "github.com/hjbdev/patterm/internal/protocol" +) + +const ( + clientKeyCtrlK byte = 0x0b + clientKeyCtrlBracket byte = 0x1d +) + +type ClientOptions struct { + ProjectDir string + Transport protocol.Transport + Stdin io.Reader + Stdout io.Writer + RawMode bool + AutoStart bool + Cols uint16 + Rows uint16 +} + +func RunAttachedClient(ctx context.Context, opts ClientOptions) error { + if opts.ProjectDir == "" { + cwd, err := os.Getwd() + if err != nil { + return err + } + opts.ProjectDir = cwd + } + if opts.Stdin == nil { + opts.Stdin = os.Stdin + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Transport == nil { + t, err := dialDaemonTransport(opts.ProjectDir, opts.AutoStart) + if err != nil { + return err + } + opts.Transport = t + defer t.Close() + } + if opts.Cols == 0 || opts.Rows == 0 { + opts.Cols, opts.Rows = clientHostSize(opts.Stdin) + } + c := newNetClient(opts) + return c.run(ctx) +} + +func dialDaemonTransport(projectDir string, autoStart bool) (protocol.Transport, error) { + socket, _, err := RuntimeDaemonPaths() + if err != nil { + return nil, err + } + conn, err := net.Dial("unix", socket) + if err == nil { + return protocol.NewConnTransport(conn), nil + } + if !autoStart { + return nil, err + } + if err := startDaemonProcess(projectDir); err != nil { + return nil, err + } + deadline := time.Now().Add(5 * time.Second) + var last error + for time.Now().Before(deadline) { + conn, err = net.Dial("unix", socket) + if err == nil { + return protocol.NewConnTransport(conn), nil + } + last = err + time.Sleep(50 * time.Millisecond) + } + return nil, fmt.Errorf("daemon did not become ready: %w", last) +} + +func startDaemonProcess(projectDir string) error { + exe, err := os.Executable() + if err != nil { + return err + } + cmd := exec.Command(exe, "daemon", "--project", projectDir) + devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err == nil { + defer devNull.Close() + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + } + cmd.Env = os.Environ() + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +type netClient struct { + t protocol.Transport + in io.Reader + out io.Writer + raw bool + projectDir string + layout terminalLayout + + mu sync.Mutex + focusedID string + chrome chromeModel + renderer *viewportRenderer + palette *clientCommandPrompt +} + +type clientCommandPrompt struct { + buf []byte +} + +func newNetClient(opts ClientOptions) *netClient { + layout := newTerminalLayout(opts.Cols, opts.Rows) + return &netClient{ + t: opts.Transport, + in: opts.Stdin, + out: opts.Stdout, + raw: opts.RawMode, + projectDir: opts.ProjectDir, + layout: layout, + renderer: newViewportRenderer(layout), + } +} + +func (c *netClient) run(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + var restore *term.State + if c.raw { + if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + st, err := term.MakeRaw(int(f.Fd())) + if err != nil { + return err + } + restore = st + defer term.Restore(int(f.Fd()), restore) + } + } + c.enterScreen() + defer c.leaveScreen() + + if err := c.sendAttach(); err != nil { + return err + } + errCh := make(chan error, 2) + go func() { errCh <- c.recvLoop(ctx, cancel) }() + go func() { errCh <- c.stdinLoop(ctx, cancel) }() + if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + winch := make(chan os.Signal, 1) + signal.Notify(winch, syscall.SIGWINCH) + defer signal.Stop(winch) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-winch: + cols, rows := clientHostSize(c.in) + _ = c.resize(cols, rows) + c.enterScreen() + c.drawChrome() + } + } + }() + } + select { + case <-ctx.Done(): + _ = c.t.Close() + return nil + case err := <-errCh: + cancel() + _ = c.t.Close() + if errors.Is(err, io.EOF) || errors.Is(err, protocol.ErrTransportClosed) { + return nil + } + return err + } +} + +func (c *netClient) sendAttach() error { + f, err := protocol.NewFrame(protocol.FrameAttach, protocol.Attach{ + ProjectPath: c.projectPath(), + TermSize: protocol.Size{ + Cols: c.layout.childCols(), + Rows: c.layout.childRows(), + }, + }) + if err != nil { + return err + } + return c.t.Send(f) +} + +func (c *netClient) projectPath() string { + return c.projectDir +} + +func (c *netClient) recvLoop(ctx context.Context, cancel func()) error { + for { + select { + case <-ctx.Done(): + return nil + default: + } + f, err := c.t.Recv() + if err != nil { + return err + } + if err := c.handleFrame(f); err != nil { + return err + } + if f.Type == protocol.FrameDetach { + cancel() + return nil + } + } +} + +func (c *netClient) handleFrame(f protocol.Frame) error { + switch f.Type { + case protocol.FrameError: + msg, _ := protocol.Decode[protocol.Error](f) + if msg.Message == "" { + msg.Message = "daemon error" + } + return fmt.Errorf("%s", msg.Message) + case protocol.FrameHello: + return nil + case protocol.FrameProjectList: + return nil + case protocol.FrameChrome: + msg, err := protocol.Decode[protocol.Chrome](f) + if err != nil { + return err + } + var model chromeModel + if err := json.Unmarshal(msg.Model, &model); err != nil { + return err + } + c.mu.Lock() + c.chrome = model + if model.FocusedID != "" { + c.focusedID = model.FocusedID + } + c.mu.Unlock() + c.drawChrome() + case protocol.FramePaneSnapshot: + msg, err := protocol.Decode[protocol.PaneSnapshot](f) + if err != nil { + return err + } + c.mu.Lock() + c.focusedID = msg.PaneID + c.renderer = newViewportRenderer(c.layout) + renderer := c.renderer + c.mu.Unlock() + c.clearViewport() + c.writeWrapped(renderer.Render(msg.Bytes)) + case protocol.FramePaneChunk: + msg, err := protocol.Decode[protocol.PaneChunk](f) + if err != nil { + return err + } + c.mu.Lock() + focused := c.focusedID + renderer := c.renderer + c.mu.Unlock() + if msg.PaneID == focused && renderer != nil { + c.writeWrapped(renderer.Render(msg.Bytes)) + } + case protocol.FrameLifecycle: + // The daemon follows lifecycle changes with chrome/snapshot updates + // when focus changes. Keep this as a wake point for future richer + // client-side state without blocking the frame stream. + return nil + } + return nil +} + +func (c *netClient) stdinLoop(ctx context.Context, cancel func()) error { + buf := make([]byte, 4096) + for { + n, err := c.in.Read(buf) + if n > 0 { + if done, perr := c.processInput(buf[:n]); perr != nil || done { + cancel() + return perr + } + } + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + select { + case <-ctx.Done(): + return nil + default: + } + } +} + +func (c *netClient) processInput(chunk []byte) (bool, error) { + c.mu.Lock() + if c.palette != nil { + p := c.palette + c.mu.Unlock() + return c.processPaletteInput(p, chunk) + } + c.mu.Unlock() + + forward := make([]byte, 0, len(chunk)) + flush := func() error { + if len(forward) == 0 { + return nil + } + c.mu.Lock() + paneID := c.focusedID + c.mu.Unlock() + if paneID != "" { + f, err := protocol.NewFrame(protocol.FrameInput, protocol.Input{PaneID: paneID, Bytes: append([]byte(nil), forward...)}) + if err != nil { + return err + } + if err := c.t.Send(f); err != nil { + return err + } + } + forward = forward[:0] + return nil + } + for _, b := range chunk { + switch b { + case clientKeyCtrlBracket: + if err := flush(); err != nil { + return false, err + } + return true, c.sendDetach() + case clientKeyCtrlK: + if err := flush(); err != nil { + return false, err + } + c.mu.Lock() + c.palette = &clientCommandPrompt{} + c.mu.Unlock() + c.drawPrompt() + case 0x17: // Ctrl-W: previous focus + if err := flush(); err != nil { + return false, err + } + _ = c.focusRelative(-1) + case 0x13: // Ctrl-S: next focus + if err := flush(); err != nil { + return false, err + } + _ = c.focusRelative(1) + default: + forward = append(forward, b) + } + } + return false, flush() +} + +func (c *netClient) processPaletteInput(p *clientCommandPrompt, chunk []byte) (bool, error) { + for _, b := range chunk { + switch b { + case 0x1b: // ESC + c.mu.Lock() + c.palette = nil + c.mu.Unlock() + c.drawChrome() + return false, nil + case 'd': + if len(p.buf) == 0 { + c.mu.Lock() + c.palette = nil + c.mu.Unlock() + return true, c.sendDetach() + } + p.buf = append(p.buf, b) + case '\r', '\n': + command := strings.TrimSpace(string(p.buf)) + c.mu.Lock() + c.palette = nil + c.mu.Unlock() + if command == "" { + c.drawChrome() + return false, nil + } + return false, c.sendSpawnCommand(command) + case 0x7f, 0x08: + if len(p.buf) > 0 { + p.buf = p.buf[:len(p.buf)-1] + } + c.drawPrompt() + default: + if b >= 0x20 { + p.buf = append(p.buf, b) + c.drawPrompt() + } + } + } + return false, nil +} + +func (c *netClient) sendDetach() error { + f, err := protocol.NewFrame(protocol.FrameDetach, protocol.Detach{}) + if err != nil { + return err + } + return c.t.Send(f) +} + +func (c *netClient) sendSpawnCommand(command string) error { + data, err := json.Marshal(map[string]any{ + "argv": []string{command}, + "name": command, + "shell": true, + }) + if err != nil { + return err + } + f, err := protocol.NewFrame(protocol.FramePaletteCommand, protocol.PaletteCommand{ + Kind: "spawn_command", + Data: data, + }) + if err != nil { + return err + } + return c.t.Send(f) +} + +func (c *netClient) focusRelative(delta int) error { + c.mu.Lock() + model := c.chrome + current := c.focusedID + c.mu.Unlock() + ids := make([]string, 0, len(model.Processes)+len(model.AgentTree)+len(model.Tabs)) + for _, n := range model.Sidebar { + if n.ChildID != "" { + ids = append(ids, n.ChildID) + } + } + if len(ids) == 0 { + for _, p := range model.Processes { + ids = append(ids, p.ID) + } + for _, p := range model.Tabs { + ids = append(ids, p.ID) + } + } + if len(ids) == 0 { + return nil + } + idx := 0 + for i, id := range ids { + if id == current { + idx = i + break + } + } + idx = (idx + delta + len(ids)) % len(ids) + f, err := protocol.NewFrame(protocol.FrameFocus, protocol.Focus{PaneID: ids[idx]}) + if err != nil { + return err + } + return c.t.Send(f) +} + +func (c *netClient) resize(cols, rows uint16) error { + c.mu.Lock() + c.layout = newTerminalLayout(cols, rows) + if c.renderer != nil { + c.renderer.SetLayout(c.layout) + } + size := protocol.Size{Cols: c.layout.childCols(), Rows: c.layout.childRows()} + c.mu.Unlock() + f, err := protocol.NewFrame(protocol.FrameResize, protocol.Resize{Size: size}) + if err != nil { + return err + } + return c.t.Send(f) +} + +func (c *netClient) enterScreen() { + _, _ = c.out.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h")) + c.installScrollRegion() +} + +func (c *netClient) leaveScreen() { + _, _ = c.out.Write([]byte("\x1b[r\x1b[?6l\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l")) +} + +func (c *netClient) installScrollRegion() { + mainBottom := int(c.layout.statusRow) - statusRows + if mainBottom < int(c.layout.mainTop) { + return + } + fmt.Fprintf(c.out, "\x1b[?6l\x1b[%d;%dr\x1b[%d;%dH", + int(c.layout.mainTop), mainBottom, + int(c.layout.mainTop), int(c.layout.mainLeft)) +} + +func (c *netClient) clearViewport() { + for row := int(c.layout.mainTop); row < int(c.layout.statusRow); row++ { + fmt.Fprintf(c.out, "\x1b[%d;%dH\x1b[%dX", row, int(c.layout.mainLeft), int(c.layout.childCols())) + } + fmt.Fprintf(c.out, "\x1b[%d;%dH", int(c.layout.mainTop), int(c.layout.mainLeft)) +} + +func (c *netClient) writeWrapped(out []byte) { + if len(out) == 0 { + return + } + wrapped := make([]byte, 0, len(out)+10) + wrapped = append(wrapped, "\x1b[?7l"...) + wrapped = append(wrapped, out...) + wrapped = append(wrapped, "\x1b[?7h"...) + _, _ = c.out.Write(wrapped) +} + +func (c *netClient) drawChrome() { + c.mu.Lock() + model := c.chrome + prompt := c.palette + c.mu.Unlock() + var b strings.Builder + width := int(c.layout.childCols()) + fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX\x1b[2;1H\x1b[%dX\x1b[3;1H\x1b[%dX", width, width, width) + if len(model.Tabs) == 0 { + fmt.Fprintf(&b, "\x1b[1;2H%s+ new%s", styleDim, styleReset) + } else { + col := 1 + for _, tab := range model.Tabs { + label := fitName(tab.Name, 18) + style := styleHint + if tab.ID == model.ActiveAgentID || tab.ID == model.FocusedID { + style = styleActive + } + fmt.Fprintf(&b, "\x1b[1;%dH%s %s %s", col, style, label, styleReset) + col += visibleLen(label) + 3 + if col >= width { + break + } + } + } + fmt.Fprintf(&b, "\x1b[3;1H%s%s%s", styleBorder, strings.Repeat("─", width), styleReset) + if c.layout.sidebarVisible { + c.appendSidebar(&b, model) + } + status := "Ctrl-K command palette · Ctrl-] detach" + if model.FocusedID != "" { + status = fmt.Sprintf("%s · %s", model.FocusedID, status) + } + if prompt != nil { + status = "command: " + string(prompt.buf) + } + fmt.Fprintf(&b, "\x1b[%d;1H\x1b[7m%s%s", int(c.layout.statusRow), fitName(status, int(c.layout.hostCols)), styleReset) + _, _ = c.out.Write([]byte(b.String())) +} + +func (c *netClient) appendSidebar(b *strings.Builder, model chromeModel) { + border := int(c.layout.sidebarLeft) - 1 + for row := 1; row <= int(c.layout.statusRow)-1; row++ { + fmt.Fprintf(b, "\x1b[%d;%dH%s│%s", row, border, styleBorder, styleReset) + } + col := int(c.layout.sidebarLeft) + row := 1 + write := func(text string) { + if row >= int(c.layout.statusRow) { + return + } + fmt.Fprintf(b, "\x1b[%d;%dH%-*s", row, col, int(c.layout.sidebarWidth)-1, fitName(text, int(c.layout.sidebarWidth)-1)) + row++ + } + write(styleActive + "Processes" + styleReset) + for _, p := range model.Processes { + prefix := " " + if p.ID == model.FocusedID { + prefix = "▎ " + } + write(prefix + p.Name) + } + row++ + write(styleActive + "Agent Tree" + styleReset) + for _, p := range model.AgentTree { + prefix := " " + if p.ID == model.FocusedID { + prefix = "▎ " + } + write(prefix + p.Name) + } + row++ + write(styleActive + "Scratchpads" + styleReset) + for _, p := range model.Scratchpads { + write(" " + p.Name) + } +} + +func (c *netClient) drawPrompt() { + c.drawChrome() +} + +func clientHostSize(r io.Reader) (cols, rows uint16) { + if f, ok := r.(*os.File); ok { + ws, err := cpty.GetsizeFull(f) + if err == nil && ws.Cols > 0 && ws.Rows > 0 { + return ws.Cols, ws.Rows + } + } + return 120, 40 +} diff --git a/internal/app/client_net_test.go b/internal/app/client_net_test.go new file mode 100644 index 0000000..ef236a4 --- /dev/null +++ b/internal/app/client_net_test.go @@ -0,0 +1,157 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "io" + "sync" + "testing" + "time" + + "github.com/hjbdev/patterm/internal/protocol" +) + +func TestNetClientFrameLoopSendsFocusedInput(t *testing.T) { + clientT, daemonT := protocol.NewLoopbackPair() + inR, inW := ioPipe(t) + out := &lockedBuffer{} + + gotInput := make(chan protocol.Input, 1) + errCh := make(chan error, 1) + go func() { + f, err := daemonT.Recv() + if err != nil { + errCh <- err + return + } + if f.Type != protocol.FrameAttach { + t.Errorf("first frame = %s, want attach", f.Type) + errCh <- nil + return + } + sendTestFrame(t, daemonT, protocol.FrameHello, protocol.Hello{Version: 1, ClientID: "test", ProjectKey: "project"}) + sendTestFrame(t, daemonT, protocol.FrameProjectList, protocol.ProjectList{}) + model := chromeModel{ + ProjectKey: "project", + FocusedID: "p1", + Processes: []childModel{{ID: "p1", Name: "shell", Kind: string(KindCommand), Status: string(StatusRunning)}}, + Sidebar: []navEntryModel{{ChildID: "p1"}}, + } + sendTestFrame(t, daemonT, protocol.FrameChrome, protocol.Chrome{ProjectKey: "project", Model: mustMarshalTest(t, model)}) + sendTestFrame(t, daemonT, protocol.FramePaneSnapshot, protocol.PaneSnapshot{PaneID: "p1", Bytes: []byte("READY")}) + for { + f, err := daemonT.Recv() + if err != nil { + errCh <- err + return + } + if f.Type != protocol.FrameInput { + continue + } + input, err := protocol.Decode[protocol.Input](f) + if err != nil { + errCh <- err + return + } + gotInput <- input + _ = daemonT.Close() + errCh <- nil + return + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + runCh := make(chan error, 1) + go func() { + runCh <- RunAttachedClient(ctx, ClientOptions{ + Transport: clientT, + Stdin: inR, + Stdout: out, + Cols: 80, + Rows: 24, + }) + }() + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) && !bytes.Contains(out.Bytes(), []byte("READY")) { + time.Sleep(10 * time.Millisecond) + } + if !bytes.Contains(out.Bytes(), []byte("READY")) { + t.Fatalf("snapshot was not rendered before input; output=%q", out.String()) + } + if _, err := inW.Write([]byte("echo hi\r")); err != nil { + t.Fatalf("write stdin: %v", err) + } + select { + case input := <-gotInput: + if input.PaneID != "p1" || string(input.Bytes) != "echo hi\r" { + t.Fatalf("input = %#v", input) + } + case <-time.After(3 * time.Second): + t.Fatalf("client did not forward input") + } + cancel() + _ = inW.Close() + select { + case err := <-runCh: + if err != nil { + t.Fatalf("client run: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatalf("client did not stop") + } + if err := <-errCh; err != nil && err != protocol.ErrTransportClosed { + t.Fatalf("daemon side: %v", err) + } +} + +type lockedBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.b.Write(p) +} + +func (b *lockedBuffer) Bytes() []byte { + b.mu.Lock() + defer b.mu.Unlock() + return append([]byte(nil), b.b.Bytes()...) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.b.String() +} + +func ioPipe(t *testing.T) (*io.PipeReader, *io.PipeWriter) { + t.Helper() + r, w := io.Pipe() + return r, w +} + +func sendTestFrame[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 mustMarshalTest(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} diff --git a/internal/harness/restart_persist_test.go b/internal/harness/restart_persist_test.go index 658ed5b..a7460b8 100644 --- a/internal/harness/restart_persist_test.go +++ b/internal/harness/restart_persist_test.go @@ -143,7 +143,7 @@ func openSession(t *testing.T, env *testEnv, childEnv []string) *Session { if err != nil { t.Fatalf("vt emulator: %v", err) } - p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows) + p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows) if err != nil { _ = em.Close() t.Fatalf("pty start: %v", err) diff --git a/internal/harness/session.go b/internal/harness/session.go index 94cdf56..bea0b52 100644 --- a/internal/harness/session.go +++ b/internal/harness/session.go @@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) { if err != nil { return nil, err } - p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows) + p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows) if err != nil { _ = em.Close() return nil, err