diff --git a/internal/session/host.go b/internal/session/host.go index 5e92fe0..00384e1 100644 --- a/internal/session/host.go +++ b/internal/session/host.go @@ -366,16 +366,44 @@ func titleSequence(label string) string { return "\x1b]0;" + clean + "\x07" } -func (h *host) applyResize(cols, rows int) { - if cols <= 0 || rows <= 0 || cols > 1000 || rows > 1000 { +func validSize(cols, rows int) bool { + return cols > 0 && rows > 0 && cols <= 1000 && rows <= 1000 +} + +func resizeNudge(cols, rows int) (int, int, bool) { + if !validSize(cols, rows) { + return 0, 0, false + } + if rows > 1 { + return cols, rows - 1, true + } + if cols > 1 { + return cols - 1, rows, true + } + return 0, 0, false +} + +func (h *host) applyResizeLocked(cols, rows int) { + if !validSize(cols, rows) { return } - h.mu.Lock() - defer h.mu.Unlock() h.term.Resize(cols, rows) + h.applyPTYSizeLocked(cols, rows) +} + +func (h *host) applyPTYSizeLocked(cols, rows int) { + if !validSize(cols, rows) { + return + } _ = pty.Setsize(h.ptmx, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}) // #nosec G115 -- bounds checked above } +func (h *host) applyResize(cols, rows int) { + h.mu.Lock() + defer h.mu.Unlock() + h.applyResizeLocked(cols, rows) +} + func (h *host) handleAttach(conn net.Conn, br *bufio.Reader, req request) { cl := &attachClient{conn: conn, out: make(chan []byte, attachBufFrames)} h.mu.Lock() @@ -387,24 +415,25 @@ func (h *host) handleAttach(conn net.Conn, br *bufio.Reader, req request) { _ = conn.Close() return } - // Register and queue the screen replay atomically, so no live broadcast - // can interleave ahead of it. The replay paints the current screen - // immediately; the follow-up resize makes full-screen TUIs repaint - // themselves with real colors/attributes. + // Apply the attaching geometry, then register and queue the screen replay + // atomically so no live broadcast can interleave ahead of it. h.mu.Lock() - curCols, curRows := h.term.Size() - cl.out <- append([]byte(titleSequence(label)), h.term.Redraw()...) - h.clients[cl] = struct{}{} - h.mu.Unlock() - go h.attachWriter(cl) - if req.Cols > 0 && req.Rows > 0 { + if validSize(req.Cols, req.Rows) { + curCols, curRows := h.term.Size() if req.Cols == curCols && req.Rows == curRows { // Same geometry produces no SIGWINCH; nudge the size so the - // agent's TUI still repaints for the new viewer. - h.applyResize(req.Cols, req.Rows-1) + // agent's TUI still repaints for the new viewer without truncating + // the replay buffer before Redraw. + if cols, rows, ok := resizeNudge(req.Cols, req.Rows); ok { + h.applyPTYSizeLocked(cols, rows) + } } - h.applyResize(req.Cols, req.Rows) + h.applyResizeLocked(req.Cols, req.Rows) } + cl.out <- append([]byte(titleSequence(label)), h.term.Redraw()...) + h.clients[cl] = struct{}{} + h.mu.Unlock() + go h.attachWriter(cl) h.attachReader(cl, br) } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index a459aa1..cb8d6a4 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -447,6 +447,159 @@ func TestAttachOwnsTerminalStateOnTTY(t *testing.T) { } } +type attachedPTY struct { + ptmx *os.File + done chan error + snapshot func() string +} + +func startQuietAttach(t *testing.T, dir, name string, cols, rows uint16) *attachedPTY { + t.Helper() + ptmx, tty, err := pty.Open() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ptmx.Close(); _ = tty.Close() }) + if err := pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows}); err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { done <- runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}) }() + return &attachedPTY{ptmx: ptmx, done: done, snapshot: capturePTYOutput(ptmx)} +} + +func capturePTYOutput(ptmx *os.File) func() string { + var mu sync.Mutex + var got []byte + go func() { + buf := make([]byte, 4096) + for { + n, rerr := ptmx.Read(buf) + if n > 0 { + mu.Lock() + got = append(got, buf[:n]...) + mu.Unlock() + } + if rerr != nil { + return + } + } + }() + return func() string { + mu.Lock() + defer mu.Unlock() + return string(got) + } +} + +func (a *attachedPTY) Snapshot() string { return a.snapshot() } + +func (a *attachedPTY) Detach(t *testing.T) { + t.Helper() + if _, err := a.ptmx.Write([]byte{0x02, 'd'}); err != nil { // Ctrl+B d + t.Fatal(err) + } + select { + case err := <-a.done: + if err != nil { + t.Fatalf("runAttach: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("detach chord did not detach") + } +} + +func TestResizeNudgeBounds(t *testing.T) { + cols, rows, ok := resizeNudge(80, 24) + if !ok || cols != 80 || rows != 23 { + t.Fatalf("row nudge = %dx%d %v, want 80x23 true", cols, rows, ok) + } + + cols, rows, ok = resizeNudge(2, 1) + if !ok || cols != 1 || rows != 1 { + t.Fatalf("column nudge = %dx%d %v, want 1x1 true", cols, rows, ok) + } + + if _, _, ok := resizeNudge(1, 1); ok { + t.Fatal("1x1 terminal must not be nudged") + } + if _, _, ok := resizeNudge(0, 24); ok { + t.Fatal("invalid terminal size must not be nudged") + } + + h := &host{} + h.applyResizeLocked(0, 24) + h.applyPTYSizeLocked(80, 0) +} + +func TestAttachReplayUsesCurrentTerminalSize(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-eeee9999" + cmd := []string{"/bin/sh", "-c", "printf '\033[999;1Hedge\033[999;999H'; sleep 60"} + if err := c.CreateSession(ctx, name, t.TempDir(), nil, cmd); err != nil { + t.Fatalf("CreateSession: %v", err) + } + waitFor(t, "edge", func() bool { + out, _ := c.Capture(ctx, name, 10) + return strings.Contains(out, "edge") + }) + + attached := startQuietAttach(t, c.Dir, name, 80, 24) + waitFor(t, "initial replay cursor", func() bool { + out := attached.Snapshot() + return strings.Contains(out, "\x1b[24;80H") || strings.Contains(out, "\x1b[50;200H") + }) + out := attached.Snapshot() + if !strings.Contains(out, "edge") { + t.Fatalf("attach replay missing edge marker: %q", out) + } + if !strings.Contains(out, "\x1b[24;80H") { + t.Fatalf("attach replay must park cursor using attached terminal size: %q", out) + } + if strings.Contains(out, "\x1b[50;200H") { + t.Fatalf("attach replay must not use detached terminal size: %q", out) + } + attached.Detach(t) +} + +func TestSameSizeAttachNudgeDoesNotTruncateReplay(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-dddd9999" + cmd := []string{"/bin/sh", "-c", `while IFS= read -r line; do [ "$line" = paint ] && printf '\033[Htop-row-safe\033[24;1Hbottom-row-guard\033[H'; done`} + if err := c.CreateSession(ctx, name, t.TempDir(), nil, cmd); err != nil { + t.Fatalf("CreateSession: %v", err) + } + + attachOnce := func(what string, ready func(string) bool) string { + attached := startQuietAttach(t, c.Dir, name, 80, 24) + waitFor(t, what, func() bool { return ready(attached.Snapshot()) }) + out := attached.Snapshot() + attached.Detach(t) + return out + } + + attachOnce("initial empty replay", func(out string) bool { + return strings.Contains(out, "\x1b[1;1H") + }) + if err := c.SendLine(ctx, name, "paint"); err != nil { + t.Fatalf("SendLine: %v", err) + } + waitFor(t, "painted screen", func() bool { + out, _ := c.Capture(ctx, name, 30) + return strings.Contains(out, "top-row-safe") && strings.Contains(out, "bottom-row-guard") + }) + + out := attachOnce("same-size replay", func(out string) bool { + return strings.Contains(out, "bottom-row-guard") + }) + if !strings.Contains(out, "top-row-safe") { + t.Fatalf("same-size attach replay lost top-row content: %q", out) + } +} + // Detaching while the agent is mid-burst must not scribble the primary // screen: bytes still buffered in the host→terminal pump at detach time have // to be drained inside the alternate screen, so nothing follows the