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
39 changes: 28 additions & 11 deletions backend/internal/adapters/runtime/zellij/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,20 @@ func versionArgs() []string {
}

func createSessionArgs(id, layoutPath string) []string {
return []string{
clientOptions := embeddedClientOptions()
args := make([]string, 0, 6+len(clientOptions)+6)
args = append(args,
"attach", "--create-background", id,
"options",
"--default-layout", layoutPath,
"--pane-frames", "false",
)
args = append(args, clientOptions...)
args = append(args,
"--session-serialization", "false",
"--show-startup-tips", "false",
"--show-release-notes", "false",
}
)
return args
}

func listPanesArgs(id string) []string {
Expand Down Expand Up @@ -68,10 +73,25 @@ func deleteSessionArgs(id string) []string {
}

func attachArgs(id string) []string {
return []string{
clientOptions := embeddedClientOptions()
args := make([]string, 0, 3+len(clientOptions))
args = append(args,
"attach", id,
"options",
)
args = append(args, clientOptions...)
return args
}

func embeddedClientOptions() []string {
return []string{
"--pane-frames", "false",
"--mouse-mode", "false",
"--advanced-mouse-actions", "false",
"--mouse-hover-effects", "false",
"--focus-follows-mouse", "false",
"--mouse-click-through", "false",
"--support-kitty-keyboard-protocol", "false",
}
}

Expand All @@ -88,7 +108,7 @@ func buildLayout(cfg ports.RuntimeConfig, shellPath string) string {
return directLayoutString(cfg.WorkspacePath, cfg.Argv)
}
spec := shellLaunchSpecFor(shellPath)
shellCommand := shellLaunchCommand(cfg, shellPath, spec)
shellCommand := shellLaunchCommand(cfg, spec)
return layoutString(cfg.WorkspacePath, shellPath, spec.args, shellCommand)
}

Expand Down Expand Up @@ -176,17 +196,17 @@ func layoutString(workspacePath, shellPath string, shellArgs []string, shellComm
"}\n"
}

func shellLaunchCommand(cfg ports.RuntimeConfig, shellPath string, spec shellLaunchSpec) string {
func shellLaunchCommand(cfg ports.RuntimeConfig, spec shellLaunchSpec) string {
if len(spec.args) > 0 && spec.args[0] == "-NoLogo" {
return wrapLaunchCommandPowerShell(cfg)
}
if len(spec.args) > 0 && spec.args[0] == "/D" {
return wrapLaunchCommandCmd(cfg)
}
return wrapLaunchCommandUnix(cfg, shellPath)
return wrapLaunchCommandUnix(cfg)
}

func wrapLaunchCommandUnix(cfg ports.RuntimeConfig, shellPath string) string {
func wrapLaunchCommandUnix(cfg ports.RuntimeConfig) string {
path := cfg.Env["PATH"]
if path == "" {
path = getenv("PATH")
Expand All @@ -209,9 +229,6 @@ func wrapLaunchCommandUnix(cfg ports.RuntimeConfig, shellPath string) string {
b.WriteString("; ")
}
b.WriteString(quoteArgvUnix(cfg.Argv))
b.WriteString("; exec ")
b.WriteString(shellQuote(shellPath))
b.WriteString(" -i")
return b.String()
}

Expand Down
17 changes: 15 additions & 2 deletions backend/internal/adapters/runtime/zellij/zellij.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,10 @@ func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error
if err != nil {
return err
}
if _, err := r.run(ctx, deleteSessionArgs(id)...); err != nil {
out, err := r.run(ctx, deleteSessionArgs(id)...)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if errors.As(err, &exitErr) && deleteSessionMissingOutput(string(out)) {
return nil
}
return fmt.Errorf("zellij runtime: destroy session %s: %w", id, err)
Expand Down Expand Up @@ -357,6 +358,18 @@ func noActiveSessionsOutput(out string) bool {
return strings.Contains(s, "no active") && strings.Contains(s, "session")
}

func deleteSessionMissingOutput(out string) bool {
s := strings.ToLower(out)
if noActiveSessionsOutput(s) {
return true
}
return strings.Contains(s, "session") &&
(strings.Contains(s, "not found") ||
strings.Contains(s, "does not exist") ||
strings.Contains(s, "not exist") ||
strings.Contains(s, "not a session"))
}

// AttachCommand returns the argv a human runs to attach their terminal to the
// session, plus an optional env block that the spawn should apply (used on
// Windows where wrapping the attach in an `env` shim is unsafe under ConPTY).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestRuntimeIntegration(t *testing.T) {
}
r := New(opts)
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: id})
argv := []string{"sh", "-c", "echo ready-$AO_SESSION_ID"}
argv := []string{"sh", "-lc", "printf ready-$AO_SESSION_ID\\n; exec sh -i"}
sendCommand := "echo hello-from-zellij"
if runtime.GOOS == "windows" {
argv = []string{"cmd.exe", "/D", "/Q", "/K", "echo ready-%AO_SESSION_ID%"}
Expand Down Expand Up @@ -117,7 +117,11 @@ func buildAOForIntegration(t *testing.T) string {

func tempSocketDir(t *testing.T, pattern string) string {
t.Helper()
socketDir, err := os.MkdirTemp(os.TempDir(), pattern)
parent := os.TempDir()
if runtime.GOOS != "windows" {
parent = "/tmp"
}
socketDir, err := os.MkdirTemp(parent, pattern)
if err != nil {
t.Fatalf("mkdir socket dir: %v", err)
}
Expand All @@ -144,7 +148,7 @@ func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) {
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_zj_exact_long",
WorkspacePath: t.TempDir(),
Argv: []string{"printf", "ready\n"},
Argv: []string{"sh", "-lc", "printf ready\\n; exec sh -i"},
})
if err != nil {
t.Fatalf("Create: %v", err)
Expand Down
57 changes: 47 additions & 10 deletions backend/internal/adapters/runtime/zellij/zellij_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,21 @@ func containsKey(values []string, key string) bool {
}

func TestCommandBuilders(t *testing.T) {
embeddedOptions := []string{
"--pane-frames", "false",
"--mouse-mode", "false",
"--advanced-mouse-actions", "false",
"--mouse-hover-effects", "false",
"--focus-follows-mouse", "false",
"--mouse-click-through", "false",
"--support-kitty-keyboard-protocol", "false",
}
if got, want := versionArgs(), []string{"--version"}; !reflect.DeepEqual(got, want) {
t.Fatalf("versionArgs = %#v, want %#v", got, want)
}
if got, want := createSessionArgs("sess-1", "/tmp/layout.kdl"), []string{"attach", "--create-background", "sess-1", "options", "--default-layout", "/tmp/layout.kdl", "--pane-frames", "false", "--session-serialization", "false", "--show-startup-tips", "false", "--show-release-notes", "false"}; !reflect.DeepEqual(got, want) {
wantCreate := append([]string{"attach", "--create-background", "sess-1", "options", "--default-layout", "/tmp/layout.kdl"}, embeddedOptions...)
wantCreate = append(wantCreate, "--session-serialization", "false", "--show-startup-tips", "false", "--show-release-notes", "false")
if got, want := createSessionArgs("sess-1", "/tmp/layout.kdl"), wantCreate; !reflect.DeepEqual(got, want) {
t.Fatalf("createSessionArgs = %#v, want %#v", got, want)
}
if got, want := listPanesArgs("sess-1"), []string{"--session", "sess-1", "action", "list-panes", "--all", "--json"}; !reflect.DeepEqual(got, want) {
Expand All @@ -112,7 +123,8 @@ func TestCommandBuilders(t *testing.T) {
if got, want := deleteSessionArgs("sess-1"), []string{"delete-session", "--force", "sess-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("deleteSessionArgs = %#v, want %#v", got, want)
}
if got, want := attachArgs("sess-1"), []string{"attach", "sess-1", "options", "--pane-frames", "false"}; !reflect.DeepEqual(got, want) {
wantAttach := append([]string{"attach", "sess-1", "options"}, embeddedOptions...)
if got, want := attachArgs("sess-1"), wantAttach; !reflect.DeepEqual(got, want) {
t.Fatalf("attachArgs = %#v, want %#v", got, want)
}
}
Expand Down Expand Up @@ -190,7 +202,7 @@ func TestHandleID(t *testing.T) {
}
}

func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) {
func TestBuildLayoutExportsEnvAndRunsAgentCommand(t *testing.T) {
oldGetenv := getenv
getenv = func(key string) string {
if key == "PATH" {
Expand Down Expand Up @@ -224,12 +236,15 @@ func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) {
"export AO_SESSION_ID='sess-1';",
"export ODD='can'\\\\''t';",
"export PATH='/custom/bin:/usr/bin';",
"'ao' 'run'; exec '/bin/zsh' -i",
"'ao' 'run'",
} {
if !strings.Contains(got, want) {
t.Fatalf("layout missing %q in %q", want, got)
}
}
if strings.Contains(got, "exec '/bin/zsh' -i") {
t.Fatalf("layout kept pane alive after agent exit: %q", got)
}
}

func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) {
Expand Down Expand Up @@ -418,20 +433,32 @@ func TestCreateClearsStaleSessionBeforeCreating(t *testing.T) {
}
}

func TestAttachCommandDisablesPaneFrames(t *testing.T) {
func TestAttachCommandUsesEmbeddedClientOptions(t *testing.T) {
r := New(Options{})
args, _, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0"})
if err != nil {
t.Fatalf("AttachCommand: %v", err)
}
embeddedOptions := []string{
"--pane-frames", "false",
"--mouse-mode", "false",
"--advanced-mouse-actions", "false",
"--mouse-hover-effects", "false",
"--focus-follows-mouse", "false",
"--mouse-click-through", "false",
"--support-kitty-keyboard-protocol", "false",
}
if runtime.GOOS == "windows" {
want := []string{r.binary, "attach", "sess-1", "options", "--pane-frames", "false"}
if !reflect.DeepEqual(args, want) {
t.Fatalf("AttachCommand = %#v, want %#v", args, want)
joined := strings.Join(args, " ")
for _, want := range embeddedOptions {
if !strings.Contains(joined, want) {
t.Fatalf("windows attach command missing %q: %#v", want, args)
}
}
return
}
want := append(expectedAttachEnvPrefix(), "zellij", "attach", "sess-1", "options", "--pane-frames", "false")
want := append(expectedAttachEnvPrefix(), r.binary, "attach", "sess-1", "options")
want = append(want, embeddedOptions...)
if !reflect.DeepEqual(args, want) {
t.Fatalf("AttachCommand = %#v, want %#v", args, want)
}
Expand Down Expand Up @@ -613,7 +640,7 @@ func TestIsAliveReportsOtherExitFailuresAsProbeErrors(t *testing.T) {
}

func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) {
fr := &fakeRunner{err: &exec.ExitError{}}
fr := &fakeRunner{outputs: [][]byte{[]byte("No active zellij sessions found.")}, err: &exec.ExitError{}}
r := New(Options{Timeout: time.Second})
r.runner = fr

Expand All @@ -625,6 +652,16 @@ func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) {
}
}

func TestDestroyReportsUnexpectedExitFailures(t *testing.T) {
fr := &fakeRunner{outputs: [][]byte{[]byte("permission denied")}, err: &exec.ExitError{}}
r := New(Options{Timeout: time.Second})
r.runner = fr

if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0"}); err == nil {
t.Fatal("Destroy: got nil, want unexpected delete-session failure")
}
}

// Destroy must delete the session's serialized state, not merely kill it: a
// killed-but-cached session is resurrected (agent re-run included) by any later
// `zellij attach`, bringing a terminated session's runtime back to life.
Expand Down
Loading
Loading