Skip to content
Open
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
7 changes: 7 additions & 0 deletions backend/internal/adapters/runtime/tmux/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ func setMouseOnArgs(id string) []string {
return []string{"set-option", "-t", id, "mouse", "on"}
}

// setWindowSizeLatestArgs makes the newest attached client drive the session's
// shared window geometry. This avoids a stale or smaller client pinning a
// browser/Electron terminal to the wrong grid.
func setWindowSizeLatestArgs(id string) []string {
return []string{"set-window-option", "-t", id, "window-size", "latest"}
}

// killSessionArgs builds args for `tmux kill-session -t =<id>`. The `=` prefix
// requests exact-name matching so a session "foo" does not accidentally match
// "foobar" (tmux otherwise does unique-prefix matching).
Expand Down
9 changes: 9 additions & 0 deletions backend/internal/adapters/runtime/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.Ru
return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: set mouse %s: %w", id, err)
}

// Pin geometry policy for ao-owned sessions instead of inheriting the host
// tmux default. Browser and Electron attaches resize their PTY before tmux
// starts; "latest" ensures the most recent live client, not the smallest
// stale/mirrored client, drives the shared window size.
if _, err := r.run(ctx, setWindowSizeLatestArgs(id)...); err != nil {
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id})
return ports.RuntimeHandle{}, fmt.Errorf("tmux runtime: set window size %s: %w", id, err)
}

handle := ports.RuntimeHandle{ID: id}
alive, err := r.IsAlive(ctx, handle)
if err != nil {
Expand Down
48 changes: 48 additions & 0 deletions backend/internal/adapters/runtime/tmux/tmux_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package tmux

import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -123,6 +126,51 @@ func TestRuntimeIntegrationExactSessionParsing(t *testing.T) {
}
}

func TestRuntimeIntegrationPinsWindowSizeLatest(t *testing.T) {
tmuxPath, err := exec.LookPath("tmux")
if err != nil {
t.Skip("tmux unavailable")
}

tmp := t.TempDir()
confPath := filepath.Join(tmp, "tmux.conf")
if err := os.WriteFile(confPath, []byte("set-window-option -g window-size smallest\n"), 0o644); err != nil {
t.Fatalf("write tmux config: %v", err)
}
socket := "ao65-" + strings.ReplaceAll(t.Name(), "/", "-")
wrapperPath := filepath.Join(tmp, "tmux-wrapper")
wrapper := fmt.Sprintf("#!/bin/sh\nexec %s -L %s -f %s \"$@\"\n", shellQuote(tmuxPath), shellQuote(socket), shellQuote(confPath))
if err := os.WriteFile(wrapperPath, []byte(wrapper), 0o755); err != nil {
t.Fatalf("write tmux wrapper: %v", err)
}

ctx := context.Background()
id := strings.ReplaceAll(t.Name(), "/", "_")
r := New(Options{Binary: wrapperPath, Timeout: 5 * time.Second})

t.Cleanup(func() {
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id})
_ = exec.Command(tmuxPath, "-L", socket, "-f", confPath, "kill-server").Run()
})

h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: domain.SessionID(id),
WorkspacePath: tmp,
Argv: []string{"sh", "-c", "echo ready"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}

out, err := exec.Command(tmuxPath, "-L", socket, "-f", confPath, "show-options", "-Awv", "-t", h.ID, "window-size").CombinedOutput()
if err != nil {
t.Fatalf("show window-size: %v: %s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "latest" {
t.Fatalf("window-size = %q, want latest", got)
}
}

// waitForOutput polls GetOutput until out contains want or the deadline passes.
func waitForOutput(t *testing.T, r *Runtime, h ports.RuntimeHandle, want string, deadline time.Duration) string {
t.Helper()
Expand Down
25 changes: 18 additions & 7 deletions backend/internal/adapters/runtime/tmux/tmux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ func TestCommandBuilders(t *testing.T) {
if got, want := setMouseOnArgs("sess-1"), []string{"set-option", "-t", "sess-1", "mouse", "on"}; !reflect.DeepEqual(got, want) {
t.Fatalf("setMouseOnArgs = %#v, want %#v", got, want)
}
if got, want := setWindowSizeLatestArgs("sess-1"), []string{"set-window-option", "-t", "sess-1", "window-size", "latest"}; !reflect.DeepEqual(got, want) {
t.Fatalf("setWindowSizeLatestArgs = %#v, want %#v", got, want)
}
// kill-session and has-session use exact-match prefix =.
if got, want := killSessionArgs("sess-1"), []string{"kill-session", "-t", "=sess-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("killSessionArgs = %#v, want %#v", got, want)
Expand Down Expand Up @@ -156,9 +159,10 @@ func TestCreateRejectsInvalidEnvKeys(t *testing.T) {
// -- Create tests --

func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) {
// new-session, set-option status, set-option mouse, has-session (exit 0 = alive)
// new-session, set-option status, set-option mouse, window-size latest,
// has-session (exit 0 = alive)
r, fr := newTestRuntime(0)
fr.outputs = [][]byte{nil, nil, nil, nil}
fr.outputs = [][]byte{nil, nil, nil, nil, nil}

h, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
Expand All @@ -172,9 +176,10 @@ func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) {
if h.ID != "sess-1" {
t.Fatalf("handle ID = %q, want sess-1", h.ID)
}
// Expect 4 calls: new-session, set-option status, set-option mouse, has-session.
if len(fr.calls) != 4 {
t.Fatalf("calls = %d, want 4", len(fr.calls))
// Expect 5 calls: new-session, set-option status, set-option mouse,
// window-size latest, has-session.
if len(fr.calls) != 5 {
t.Fatalf("calls = %d, want 5", len(fr.calls))
}

// Call 0: new-session
Expand Down Expand Up @@ -204,10 +209,16 @@ func TestCreateIssuesNewSessionAndStatusOff(t *testing.T) {
t.Fatalf("call[2] = %#v, want %#v", got, want)
}

// Call 3: has-session (IsAlive, uses exact-match target =sess-1).
if got, want := fr.calls[3].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) {
// Call 3: window-size latest so the most recent browser/Electron attach,
// not the smallest stale client, drives the shared tmux window geometry.
if got, want := fr.calls[3].args, setWindowSizeLatestArgs("sess-1"); !reflect.DeepEqual(got, want) {
t.Fatalf("call[3] = %#v, want %#v", got, want)
}

// Call 4: has-session (IsAlive, uses exact-match target =sess-1).
if got, want := fr.calls[4].args, hasSessionArgs("sess-1"); !reflect.DeepEqual(got, want) {
t.Fatalf("call[4] = %#v, want %#v", got, want)
}
}

func TestCreateLaunchCommandContainsKeepAliveShell(t *testing.T) {
Expand Down