fix: keep terminal mux persistent across navigation (#325)
* fix: keep terminal mux persistent across navigation * chore: format with prettier [skip ci] * fix: require configured agent defaults * chore: format with prettier [skip ci] * fix: recreate stale orchestrator worktrees * fix: stop terminal reattach on socket close * fix: stop terminal attach loop on close * fix: keep agent default selects controlled * revert: undo persistent mux branch changes * fix: harden terminal mux attachment flow * chore: format with prettier [skip ci] * chore: remove terminal flow diagnostics * chore: format with prettier [skip ci] * fix: satisfy zellij command lint --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
cdfec7f9d8
commit
5e8c8defbd
|
|
@ -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 {
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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%"}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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" {
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -54,38 +54,43 @@ const (
|
|||
// attachment is ONE client's hold on a pane: a private `zellij attach` PTY
|
||||
// spawned per mux open, streaming to a single sink. Zellij is the multiplexer —
|
||||
// it owns the session's screen state and scrollback, and answers every fresh
|
||||
// attach with its full init handshake (alt screen, SGR mouse tracking,
|
||||
// bracketed paste) followed by a faithful repaint. That handshake is why the
|
||||
// PTY is per-client and there is no server-side replay buffer: a byte ring
|
||||
// can replay recent output, but the one-time mode negotiation at the head of
|
||||
// the stream scrolls out of any bounded buffer, leaving late subscribers
|
||||
// without mouse reporting (wheel scroll dead). A fresh attach per client makes
|
||||
// Zellij re-send it, every time, by construction.
|
||||
// attach with its init handshake (alt screen, bracketed paste, and other terminal
|
||||
// modes enabled by the embedded client options) followed by a faithful repaint.
|
||||
// That handshake is why the PTY is per-client and there is no server-side replay
|
||||
// buffer: a byte ring can replay recent output, but the one-time mode negotiation
|
||||
// at the head of the stream scrolls out of any bounded buffer. A fresh attach per
|
||||
// client makes Zellij re-send it, every time, by construction.
|
||||
//
|
||||
// onData must not block: the WS layer funnels frames onto its own buffered
|
||||
// writer. onExit fires at most once, when the attach loop gives up (runtime
|
||||
// dead, attach failure cap) — never on close().
|
||||
// onOpen fires once the attach PTY is actually ready to accept input. onData
|
||||
// must not block: the WS layer funnels frames onto its own buffered writer.
|
||||
// onExit fires at most once, when the attach loop gives up (runtime dead,
|
||||
// attach failure cap) — never on close().
|
||||
type attachment struct {
|
||||
id string
|
||||
handle ports.RuntimeHandle
|
||||
src PTYSource
|
||||
spawn spawnFunc
|
||||
log *slog.Logger
|
||||
onOpen func()
|
||||
onData func(data []byte)
|
||||
onExit func()
|
||||
|
||||
maxReattach int
|
||||
resetGrace time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
pty ptyProcess
|
||||
rows uint16 // last size the client asked for; re-applied on every attach
|
||||
cols uint16
|
||||
closed bool
|
||||
exited bool
|
||||
mu sync.Mutex
|
||||
pty ptyProcess
|
||||
cancel context.CancelFunc
|
||||
rows uint16 // last size the client asked for; re-applied on every attach
|
||||
cols uint16
|
||||
closed bool
|
||||
exited bool
|
||||
opened bool
|
||||
inputReady bool
|
||||
pendingInput [][]byte
|
||||
}
|
||||
|
||||
func newAttachment(id string, handle ports.RuntimeHandle, src PTYSource, spawn spawnFunc, onData func([]byte), onExit func(), log *slog.Logger) *attachment {
|
||||
func newAttachment(id string, handle ports.RuntimeHandle, src PTYSource, spawn spawnFunc, onOpen func(), onData func([]byte), onExit func(), log *slog.Logger) *attachment {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
|
|
@ -98,6 +103,7 @@ func newAttachment(id string, handle ports.RuntimeHandle, src PTYSource, spawn s
|
|||
src: src,
|
||||
spawn: spawn,
|
||||
log: log,
|
||||
onOpen: onOpen,
|
||||
onData: onData,
|
||||
onExit: onExit,
|
||||
maxReattach: defaultMaxReattach,
|
||||
|
|
@ -108,9 +114,16 @@ func newAttachment(id string, handle ports.RuntimeHandle, src PTYSource, spawn s
|
|||
// run drives attach → read-loop → re-attach until the pane exits cleanly, the
|
||||
// attachment is closed, or ctx is cancelled. It is started once per attachment.
|
||||
func (a *attachment) run(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
if !a.setRunCancel(cancel) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
defer a.clearRunCancel(cancel)
|
||||
|
||||
failures := 0
|
||||
for {
|
||||
if a.isClosed() || ctx.Err() != nil {
|
||||
if a.shouldStop(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +135,9 @@ func (a *attachment) run(ctx context.Context) {
|
|||
// not proof of death: it retries with backoff up to the same
|
||||
// consecutive-failure cap as attach failures.
|
||||
alive, err := a.src.IsAlive(ctx, a.handle)
|
||||
if a.shouldStop(ctx) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
failures++
|
||||
if failures > a.maxReattach {
|
||||
|
|
@ -139,12 +155,24 @@ func (a *attachment) run(ctx context.Context) {
|
|||
}
|
||||
|
||||
argv, env, err := a.src.AttachCommand(a.handle)
|
||||
if a.shouldStop(ctx) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.fail("attach command: " + err.Error())
|
||||
return
|
||||
}
|
||||
rows, cols := a.size()
|
||||
if a.shouldStop(ctx) {
|
||||
return
|
||||
}
|
||||
p, err := a.spawn(ctx, argv, env, rows, cols)
|
||||
if a.shouldStop(ctx) {
|
||||
if p != nil {
|
||||
_ = p.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
failures++
|
||||
if failures > a.maxReattach {
|
||||
|
|
@ -157,10 +185,17 @@ func (a *attachment) run(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
|
||||
a.setPTY(p)
|
||||
if !a.setPTY(p) {
|
||||
_ = p.Close()
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
a.copyOut(p)
|
||||
a.clearPTY(p)
|
||||
_ = p.Close()
|
||||
if a.shouldStop(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
if time.Since(start) >= a.resetGrace {
|
||||
failures = 0
|
||||
|
|
@ -215,15 +250,28 @@ func reattachBackoff(failures int) time.Duration {
|
|||
return d
|
||||
}
|
||||
|
||||
// write sends client keystrokes to the PTY. It is a no-op if no PTY is attached.
|
||||
// write sends client keystrokes to the PTY. Input that arrives after open but
|
||||
// before the attach PTY is published is buffered and flushed as soon as setPTY
|
||||
// runs, so a fast user cannot type into the attach race and lose bytes.
|
||||
func (a *attachment) write(p []byte) error {
|
||||
a.mu.Lock()
|
||||
pty := a.pty
|
||||
a.mu.Unlock()
|
||||
if pty == nil {
|
||||
return errors.New("terminal: no active pty")
|
||||
if len(p) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := pty.Write(p)
|
||||
chunk := append([]byte(nil), p...)
|
||||
|
||||
a.mu.Lock()
|
||||
if a.closed || a.exited {
|
||||
a.mu.Unlock()
|
||||
return errors.New("terminal: attachment closed")
|
||||
}
|
||||
pty := a.pty
|
||||
if pty == nil || !a.inputReady {
|
||||
a.pendingInput = append(a.pendingInput, chunk)
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
a.mu.Unlock()
|
||||
_, err := pty.Write(chunk)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -255,14 +303,55 @@ func (a *attachment) size() (rows, cols uint16) {
|
|||
// requested size onto it (see resize) — the spawn already started at the size
|
||||
// read in run, but a resize frame can land between that read and registration
|
||||
// here; the replay (Setsize + explicit WINCH) converges the late case.
|
||||
func (a *attachment) setPTY(p ptyProcess) {
|
||||
func (a *attachment) setPTY(p ptyProcess) bool {
|
||||
a.mu.Lock()
|
||||
if a.closed || a.exited {
|
||||
a.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
a.pty = p
|
||||
a.inputReady = false
|
||||
rows, cols := a.rows, a.cols
|
||||
shouldOpen := !a.opened
|
||||
if shouldOpen {
|
||||
a.opened = true
|
||||
}
|
||||
onOpen := a.onOpen
|
||||
a.mu.Unlock()
|
||||
if rows > 0 && cols > 0 {
|
||||
_ = p.Resize(rows, cols)
|
||||
}
|
||||
if shouldOpen && onOpen != nil {
|
||||
onOpen()
|
||||
}
|
||||
|
||||
for {
|
||||
a.mu.Lock()
|
||||
pending := append([][]byte(nil), a.pendingInput...)
|
||||
a.pendingInput = nil
|
||||
if len(pending) == 0 {
|
||||
a.inputReady = true
|
||||
a.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
for _, chunk := range pending {
|
||||
if _, err := p.Write(chunk); err != nil {
|
||||
a.fail("flush pending input: " + err.Error())
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *attachment) clearPTY(p ptyProcess) {
|
||||
a.mu.Lock()
|
||||
if a.pty == p {
|
||||
a.pty = nil
|
||||
a.inputReady = false
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// close detaches this client: stop re-attaching and kill the attach PTY. It
|
||||
|
|
@ -277,10 +366,33 @@ func (a *attachment) close() {
|
|||
a.closed = true
|
||||
pty := a.pty
|
||||
a.pty = nil
|
||||
a.inputReady = false
|
||||
a.pendingInput = nil
|
||||
cancel := a.cancel
|
||||
a.mu.Unlock()
|
||||
if pty != nil {
|
||||
_ = pty.Close()
|
||||
}
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *attachment) setRunCancel(cancel context.CancelFunc) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.closed {
|
||||
return false
|
||||
}
|
||||
a.cancel = cancel
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *attachment) clearRunCancel(cancel context.CancelFunc) {
|
||||
a.mu.Lock()
|
||||
a.cancel = nil
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
|
||||
func (a *attachment) isClosed() bool {
|
||||
|
|
@ -289,6 +401,10 @@ func (a *attachment) isClosed() bool {
|
|||
return a.closed
|
||||
}
|
||||
|
||||
func (a *attachment) shouldStop(ctx context.Context) bool {
|
||||
return ctx.Err() != nil || a.isClosed()
|
||||
}
|
||||
|
||||
func (a *attachment) isExited() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ func TestAttachmentStreamsRealZellijPane(t *testing.T) {
|
|||
handle, err := rt.Create(context.Background(), ports.RuntimeConfig{
|
||||
SessionID: domain.SessionID(name),
|
||||
WorkspacePath: t.TempDir(),
|
||||
Argv: []string{"printf", "AO_READY\\n"},
|
||||
Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
|
|
@ -43,7 +43,7 @@ func TestAttachmentStreamsRealZellijPane(t *testing.T) {
|
|||
t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) })
|
||||
|
||||
var got safeBytes
|
||||
a := newAttachment(name, handle, rt, defaultSpawn, got.add, nil, testLogger())
|
||||
a := newAttachment(name, handle, rt, defaultSpawn, nil, got.add, nil, testLogger())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go a.run(ctx)
|
||||
|
|
@ -52,13 +52,12 @@ func TestAttachmentStreamsRealZellijPane(t *testing.T) {
|
|||
eventually(t, 3*time.Second, func() bool { return a.write([]byte("echo AO_MARKER_42\n")) == nil })
|
||||
eventually(t, 5*time.Second, func() bool { return strings.Contains(got.string(), "AO_MARKER_42") })
|
||||
|
||||
// A fresh attach must carry zellij's init handshake: alt screen + SGR mouse
|
||||
// tracking. This is the whole point of per-client attach — late clients see
|
||||
// the mode negotiation, so wheel events are forwarded as mouse reports
|
||||
// instead of going dead.
|
||||
// A fresh attach must carry zellij's alt-screen init handshake. Mouse
|
||||
// reporting is deliberately disabled for AO's embedded client, so this test
|
||||
// should not require SGR mouse mode.
|
||||
eventually(t, 5*time.Second, func() bool {
|
||||
out := got.string()
|
||||
return strings.Contains(out, "\x1b[?1049h") && strings.Contains(out, "\x1b[?1006h")
|
||||
return strings.Contains(out, "\x1b[?1049h")
|
||||
})
|
||||
|
||||
// Kill the session: the attachment must observe it as gone and not re-attach.
|
||||
|
|
@ -89,28 +88,32 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) {
|
|||
handle, err := rt.Create(context.Background(), ports.RuntimeConfig{
|
||||
SessionID: domain.SessionID(name),
|
||||
WorkspacePath: t.TempDir(),
|
||||
Argv: []string{"printf", "AO_READY\\n"},
|
||||
Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) })
|
||||
|
||||
attachAt := func(rows, cols uint16) (*attachment, *safeBytes, context.CancelFunc) {
|
||||
attachAt := func(rows, cols uint16) (*attachment, *safeBytes, <-chan struct{}, context.CancelFunc) {
|
||||
var got safeBytes
|
||||
a := newAttachment(name, handle, rt, defaultSpawn, got.add, nil, testLogger())
|
||||
opened := make(chan struct{})
|
||||
a := newAttachment(name, handle, rt, defaultSpawn, func() { close(opened) }, got.add, nil, testLogger())
|
||||
if err := a.resize(rows, cols); err != nil {
|
||||
t.Fatalf("record size: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go a.run(ctx)
|
||||
return a, &got, cancel
|
||||
return a, &got, opened, cancel
|
||||
}
|
||||
|
||||
// Client A at 115x37: wait for the pane shell, then detach.
|
||||
a, gotA, cancelA := attachAt(37, 115)
|
||||
eventually(t, 5*time.Second, func() bool { return a.write([]byte("\n")) == nil })
|
||||
eventually(t, 5*time.Second, func() bool { return strings.Contains(gotA.string(), "AO_READY") })
|
||||
a, _, openedA, cancelA := attachAt(37, 115)
|
||||
select {
|
||||
case <-openedA:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("client A did not attach")
|
||||
}
|
||||
a.close()
|
||||
cancelA()
|
||||
|
||||
|
|
@ -118,9 +121,14 @@ func TestAttachmentReattachAdoptsNewSize(t *testing.T) {
|
|||
// frontend reconnecting. The inner pane must see B's grid (zellij chrome
|
||||
// shaves a couple rows/cols, so assert the reported cols land near 148 and
|
||||
// far from 115).
|
||||
b, gotB, cancelB := attachAt(40, 148)
|
||||
b, gotB, openedB, cancelB := attachAt(40, 148)
|
||||
defer cancelB()
|
||||
defer b.close()
|
||||
select {
|
||||
case <-openedB:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("client B did not attach")
|
||||
}
|
||||
|
||||
eventually(t, 5*time.Second, func() bool { return b.write([]byte("echo SIZE:$(stty size)\n")) == nil })
|
||||
eventually(t, 10*time.Second, func() bool {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -13,7 +14,17 @@ import (
|
|||
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func newTestAttachment(src PTYSource, spawn spawnFunc, onData func([]byte), onExit func()) *attachment {
|
||||
return newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, src, spawn, onData, onExit, testLogger())
|
||||
return newTestAttachmentWithOpen(src, spawn, nil, onData, onExit)
|
||||
}
|
||||
|
||||
func newTestAttachmentWithOpen(src PTYSource, spawn spawnFunc, onOpen func(), onData func([]byte), onExit func()) *attachment {
|
||||
return newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, src, spawn, onOpen, onData, onExit, testLogger())
|
||||
}
|
||||
|
||||
func currentPTY(a *attachment) ptyProcess {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.pty
|
||||
}
|
||||
|
||||
func TestAttachmentStreamsOutputToSink(t *testing.T) {
|
||||
|
|
@ -54,6 +65,60 @@ func TestAttachmentWriteAndResizeReachPTY(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestAttachmentSignalsOpenOnlyAfterPTYIsPublished(t *testing.T) {
|
||||
src := &fakeSource{alive: true}
|
||||
pty := newFakePTY()
|
||||
sp := &fakeSpawner{ptys: []*fakePTY{pty}}
|
||||
|
||||
opened := make(chan bool, 1)
|
||||
var a *attachment
|
||||
a = newTestAttachmentWithOpen(src, sp.spawn, func() {
|
||||
opened <- currentPTY(a) == pty
|
||||
}, nil, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go a.run(ctx)
|
||||
|
||||
select {
|
||||
case sawPTY := <-opened:
|
||||
if !sawPTY {
|
||||
t.Fatal("open callback fired before the PTY was published")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("open callback did not fire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentBuffersInputUntilPTYReady(t *testing.T) {
|
||||
src := &fakeSource{alive: true}
|
||||
pty := newFakePTY()
|
||||
spawnStarted := make(chan struct{})
|
||||
releaseSpawn := make(chan struct{})
|
||||
spawn := func(context.Context, []string, []string, uint16, uint16) (ptyProcess, error) {
|
||||
close(spawnStarted)
|
||||
<-releaseSpawn
|
||||
return pty, nil
|
||||
}
|
||||
a := newTestAttachment(src, spawn, nil, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go a.run(ctx)
|
||||
|
||||
select {
|
||||
case <-spawnStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("spawn was not reached")
|
||||
}
|
||||
if err := a.write([]byte("hello\n")); err != nil {
|
||||
t.Fatalf("write before PTY ready: %v", err)
|
||||
}
|
||||
close(releaseSpawn)
|
||||
|
||||
eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "hello\n" })
|
||||
}
|
||||
|
||||
// A size requested before the PTY exists (the open frame's cols/rows, or a
|
||||
// resize racing the attach) must not be lost: the attach applies it the moment
|
||||
// the PTY is up, instead of leaving the pane at the kernel default grid.
|
||||
|
|
@ -284,3 +349,66 @@ func TestAttachmentCloseDoesNotFireExit(t *testing.T) {
|
|||
t.Fatalf("close must stop re-attaching, got %d spawns", got)
|
||||
}
|
||||
}
|
||||
|
||||
type closeOrderPTY struct {
|
||||
*fakePTY
|
||||
ctx context.Context
|
||||
before chan struct{}
|
||||
after chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (p *closeOrderPTY) Close() error {
|
||||
p.once.Do(func() {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
close(p.after)
|
||||
default:
|
||||
close(p.before)
|
||||
}
|
||||
})
|
||||
return p.fakePTY.Close()
|
||||
}
|
||||
|
||||
func TestAttachmentCloseClosesPTYBeforeCancel(t *testing.T) {
|
||||
src := &fakeSource{alive: true}
|
||||
beforeCancel := make(chan struct{})
|
||||
afterCancel := make(chan struct{})
|
||||
var spawnCtx context.Context
|
||||
spawn := func(ctx context.Context, _ []string, _ []string, _, _ uint16) (ptyProcess, error) {
|
||||
spawnCtx = ctx
|
||||
return &closeOrderPTY{
|
||||
fakePTY: newFakePTY(),
|
||||
ctx: ctx,
|
||||
before: beforeCancel,
|
||||
after: afterCancel,
|
||||
}, nil
|
||||
}
|
||||
a := newTestAttachment(src, spawn, nil, nil)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
a.run(context.Background())
|
||||
close(done)
|
||||
}()
|
||||
eventually(t, time.Second, func() bool { return currentPTY(a) != nil })
|
||||
|
||||
a.close()
|
||||
select {
|
||||
case <-beforeCancel:
|
||||
case <-afterCancel:
|
||||
t.Fatal("attachment cancelled the run context before closing the PTY")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("PTY was not closed")
|
||||
}
|
||||
select {
|
||||
case <-spawnCtx.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("close must still cancel the attach context")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("run must return after close")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ func TestNilLoggerFallsBackToDefault(t *testing.T) {
|
|||
if mgr.log == nil {
|
||||
t.Fatal("manager logger is nil")
|
||||
}
|
||||
a := newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, &fakeSource{}, (&fakeSpawner{}).spawn, nil, nil, nil)
|
||||
a := newAttachment("t1", ports.RuntimeHandle{ID: "t1"}, &fakeSource{}, (&fakeSpawner{}).spawn, nil, nil, nil, nil)
|
||||
if a.log == nil {
|
||||
t.Fatal("attachment logger is nil")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,6 +224,9 @@ func (c *connState) openTerminal(id string, rows, cols uint16) {
|
|||
// the only thing that fires onExit — starts after the registration below.
|
||||
var a *attachment
|
||||
a = newAttachment(id, ports.RuntimeHandle{ID: id}, c.mgr.src, c.mgr.spawn,
|
||||
func() {
|
||||
c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened})
|
||||
},
|
||||
func(data []byte) {
|
||||
c.enqueue(serverMsg{
|
||||
Ch: chTerminal,
|
||||
|
|
@ -257,11 +260,6 @@ func (c *connState) openTerminal(id string, rows, cols uint16) {
|
|||
c.terms[id] = a
|
||||
c.mu.Unlock()
|
||||
|
||||
// Ack before starting the attach loop so opened always precedes any
|
||||
// data/exited frames (the single out channel preserves this order). A
|
||||
// dead pane is reported as opened followed by exited.
|
||||
c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened})
|
||||
|
||||
go func() {
|
||||
a.run(c.mgr.ctx)
|
||||
c.mgr.forget(a)
|
||||
|
|
|
|||
|
|
@ -99,6 +99,37 @@ func TestServeOpenStreamsAndWritesTerminal(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestServeBuffersInputUntilAttachReady(t *testing.T) {
|
||||
src := &fakeSource{alive: true}
|
||||
pty := newFakePTY()
|
||||
spawnStarted := make(chan struct{})
|
||||
releaseSpawn := make(chan struct{})
|
||||
spawn := func(context.Context, []string, []string, uint16, uint16) (ptyProcess, error) {
|
||||
close(spawnStarted)
|
||||
<-releaseSpawn
|
||||
return pty, nil
|
||||
}
|
||||
mgr := NewManager(src, nil, testLogger(), WithSpawn(spawn), WithHeartbeat(0))
|
||||
defer mgr.Close()
|
||||
|
||||
conn := newFakeConn()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go mgr.Serve(ctx, conn)
|
||||
|
||||
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
||||
select {
|
||||
case <-spawnStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("spawn was not reached")
|
||||
}
|
||||
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgData, Data: base64.StdEncoding.EncodeToString([]byte("status\n"))}
|
||||
close(releaseSpawn)
|
||||
|
||||
recv(t, conn, chTerminal, msgOpened, time.Second)
|
||||
eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "status\n" })
|
||||
}
|
||||
|
||||
// nextTerminal returns the next frame on conn.out (no skipping), so callers can
|
||||
// assert frame ordering rather than just presence.
|
||||
func nextTerminal(t *testing.T, c *fakeConn) serverMsg {
|
||||
|
|
@ -112,13 +143,13 @@ func nextTerminal(t *testing.T, c *fakeConn) serverMsg {
|
|||
}
|
||||
}
|
||||
|
||||
// Opening a pane whose runtime is already dead must (1) send opened before
|
||||
// exited (the dead pane is reported, not errored) and (2) clear the conn's
|
||||
// entry, so a later open for the same id on this connection is still served
|
||||
// instead of being silently dropped by the already-open guard.
|
||||
// Opening a pane whose runtime is already dead must report exited without
|
||||
// spawning an attach. The conn entry still has to clear so a later open for the
|
||||
// same id on this connection is served instead of being silently dropped by the
|
||||
// already-open guard.
|
||||
func TestServeOpenDeadRuntimeReportsExitedAndAllowsReopen(t *testing.T) {
|
||||
src := &fakeSource{alive: false}
|
||||
sp := &fakeSpawner{}
|
||||
sp := &fakeSpawner{ptys: []*fakePTY{newFakePTY()}}
|
||||
mgr := NewManager(src, nil, testLogger(), WithSpawn(sp.spawn), WithHeartbeat(0))
|
||||
defer mgr.Close()
|
||||
|
||||
|
|
@ -128,16 +159,14 @@ func TestServeOpenDeadRuntimeReportsExitedAndAllowsReopen(t *testing.T) {
|
|||
go mgr.Serve(ctx, conn)
|
||||
|
||||
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
||||
if m := nextTerminal(t, conn); m.Type != msgOpened {
|
||||
t.Fatalf("first frame = %q, want opened", m.Type)
|
||||
}
|
||||
if m := nextTerminal(t, conn); m.Type != msgExited {
|
||||
t.Fatalf("second frame = %q, want exited", m.Type)
|
||||
t.Fatalf("first frame = %q, want exited", m.Type)
|
||||
}
|
||||
if got := sp.calls(); got != 0 {
|
||||
t.Fatalf("attach must never run against a dead runtime, got %d spawns", got)
|
||||
}
|
||||
|
||||
src.setAlive(true)
|
||||
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
||||
if m := nextTerminal(t, conn); m.Type != msgOpened {
|
||||
t.Fatalf("re-open frame = %q, want opened (open was dropped, entry stuck)", m.Type)
|
||||
|
|
@ -166,6 +195,7 @@ func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) {
|
|||
p.Close() // drop the pty; IsAlive false => session exits, no re-attach
|
||||
recv(t, conn, chTerminal, msgExited, time.Second)
|
||||
|
||||
src.setAlive(true)
|
||||
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
||||
recv(t, conn, chTerminal, msgOpened, 2*time.Second)
|
||||
}
|
||||
|
|
@ -173,8 +203,9 @@ func TestServeExitAfterOpenClearsEntryAllowingReopen(t *testing.T) {
|
|||
// An attachment that exits the moment it is opened (dead runtime) fires onExit
|
||||
// from its run goroutine, racing the reopen that follows the exited frame. The
|
||||
// identity-guarded delete in onExit must never evict a successor attachment
|
||||
// registered under the same id: every reopen must be served (opened), never
|
||||
// silently dropped by the already-open guard. Stressed across many iterations
|
||||
// registered under the same id: every reopen must be served (exited again for a
|
||||
// still-dead runtime), never silently dropped by the already-open guard.
|
||||
// Stressed across many iterations
|
||||
// to shake the exit/reopen interleavings out.
|
||||
func TestServeReopenAfterImmediateExitNeverStuck(t *testing.T) {
|
||||
for i := 0; i < 400; i++ {
|
||||
|
|
@ -189,13 +220,12 @@ func TestServeReopenAfterImmediateExitNeverStuck(t *testing.T) {
|
|||
|
||||
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
||||
|
||||
recv(t, conn, chTerminal, msgOpened, time.Second)
|
||||
recv(t, conn, chTerminal, msgExited, time.Second)
|
||||
|
||||
// The reopen must be served even while the first open's exit teardown is
|
||||
// still in flight.
|
||||
conn.in <- clientMsg{Ch: chTerminal, ID: "t1", Type: msgOpen}
|
||||
recv(t, conn, chTerminal, msgOpened, time.Second)
|
||||
recv(t, conn, chTerminal, msgExited, time.Second)
|
||||
|
||||
cancel()
|
||||
mgr.Close()
|
||||
|
|
|
|||
|
|
@ -19,14 +19,17 @@ import (
|
|||
// startup, and a post-spawn TIOCSWINSZ depends on SIGWINCH delivery that can
|
||||
// race the client installing its handler — StartWithSize makes the first read
|
||||
// correct by construction. env, when non-nil, replaces the inherited
|
||||
// environment (mirrors exec.Cmd.Env semantics). ctx cancellation kills the
|
||||
// process. Windows uses a stub (see pty_windows.go) until a ConPTY path is
|
||||
// added.
|
||||
// environment (mirrors exec.Cmd.Env semantics). ctx cancellation closes the PTY
|
||||
// through the same graceful detach path as an explicit client close. Windows uses
|
||||
// a stub (see pty_windows.go) until a ConPTY path is added.
|
||||
func defaultSpawn(ctx context.Context, argv, env []string, rows, cols uint16) (ptyProcess, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("terminal: empty attach command")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
if env != nil {
|
||||
cmd.Env = env
|
||||
}
|
||||
|
|
@ -40,7 +43,12 @@ func defaultSpawn(ctx context.Context, argv, env []string, rows, cols uint16) (p
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &creackPTY{f: f, cmd: cmd}, nil
|
||||
proc := &creackPTY{f: f, cmd: cmd}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = proc.Close()
|
||||
}()
|
||||
return proc, nil
|
||||
}
|
||||
|
||||
type creackPTY struct {
|
||||
|
|
|
|||
|
|
@ -247,6 +247,8 @@ function activityDetail(status: SessionStatus): string | null {
|
|||
return "Session idle";
|
||||
case "needs_input":
|
||||
return "Waiting for your input";
|
||||
case "no_signal":
|
||||
return "No recent agent signal";
|
||||
case "working":
|
||||
return null;
|
||||
default:
|
||||
|
|
@ -261,6 +263,7 @@ const STATUS_PILL: Record<
|
|||
working: { label: "Working", tone: "var(--orange)", breathe: true },
|
||||
needs_you: { label: "Input needed", tone: "var(--amber)", breathe: false },
|
||||
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
|
||||
no_signal: { label: "No signal", tone: "var(--fg-muted)", breathe: false },
|
||||
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
|
||||
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
|
||||
idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false },
|
||||
|
|
|
|||
|
|
@ -24,13 +24,10 @@ type SessionViewProps = {
|
|||
sessionId: string;
|
||||
};
|
||||
|
||||
// The session detail screen: the persistent terminal + git rail, under the
|
||||
// shell-owned ShellTopbar. Rendered by both the project-scoped and
|
||||
// cross-project session routes. The terminal lives here (not in the shell) —
|
||||
// switching sessions only changes route params, so TanStack Router keeps this
|
||||
// component mounted and the terminal re-points its mux without remounting
|
||||
// (useTerminalSession). Leaving for the board unmounts it; a fresh server-side
|
||||
// zellij attach repaints the pane on return.
|
||||
// The session detail screen: terminal + git rail, under the shell-owned
|
||||
// ShellTopbar. Rendered by both the project-scoped and cross-project session
|
||||
// routes. TerminalPane owns the terminal lifetime and remounts by terminal
|
||||
// handle so each session gets a clean xterm/mux binding.
|
||||
//
|
||||
// The split is shadcn's resizable (react-resizable-panels v4) with a fully
|
||||
// collapsible inspector: the panel is `collapsible` and driven to 0% via the
|
||||
|
|
|
|||
|
|
@ -328,6 +328,8 @@ function sessionBadge(session: WorkspaceSession): { label: string; className: st
|
|||
switch (session.status) {
|
||||
case "needs_input":
|
||||
return { label: "Input needed", className: "text-warning" };
|
||||
case "no_signal":
|
||||
return { label: "No signal", className: "text-passive" };
|
||||
case "ci_failed":
|
||||
return { label: "CI failed", className: "text-error" };
|
||||
case "changes_requested":
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const STATUS_PILL: Record<WorkerDisplayStatus, { label: string; tone: string; br
|
|||
working: { label: "Working", tone: "var(--orange)", breathe: true },
|
||||
needs_you: { label: "Needs input", tone: "var(--amber)", breathe: false },
|
||||
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
|
||||
no_signal: { label: "No signal", tone: "var(--fg-muted)", breathe: false },
|
||||
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
|
||||
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ type TerminalPaneProps = {
|
|||
};
|
||||
|
||||
export function TerminalPane({ session, theme, daemonReady, terminalTarget }: TerminalPaneProps) {
|
||||
const terminalKey =
|
||||
terminalTarget?.kind === "reviewer" ? terminalTarget.handleId : (session?.terminalHandleId ?? "empty");
|
||||
|
||||
if (!window.ao) {
|
||||
const provider = terminalTarget?.kind === "reviewer" ? terminalTarget.harness : (session?.provider ?? "claude");
|
||||
return (
|
||||
|
|
@ -32,7 +35,15 @@ export function TerminalPane({ session, theme, daemonReady, terminalTarget }: Te
|
|||
);
|
||||
}
|
||||
|
||||
return <AttachedTerminal session={session} theme={theme} daemonReady={daemonReady} terminalTarget={terminalTarget} />;
|
||||
return (
|
||||
<AttachedTerminal
|
||||
key={terminalKey}
|
||||
session={session}
|
||||
theme={theme}
|
||||
daemonReady={daemonReady}
|
||||
terminalTarget={terminalTarget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function bannerText(state: TerminalSessionState, error?: string): string | undefined {
|
||||
|
|
@ -46,10 +57,9 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: Termi
|
|||
session && terminalTarget?.kind === "reviewer"
|
||||
? { ...session, terminalHandleId: terminalTarget.handleId }
|
||||
: session;
|
||||
// One terminal instance per pane lifetime (yyork's core rule): switching
|
||||
// sessions never remounts XtermTerminal — the attachment effect re-points
|
||||
// the mux and clears the screen instead. A keyed remount would tear down the
|
||||
// renderer mid-switch and lose the warm GPU surface.
|
||||
// One terminal instance per handle-scoped pane lifetime. TerminalPane keys this
|
||||
// component by terminal handle, so session switches get a fresh xterm + mux
|
||||
// hook state instead of reusing a potentially stale screen/input binding.
|
||||
const [terminal, setTerminal] = useState<AttachableTerminal | null>(null);
|
||||
const [initFailed, setInitFailed] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
|
|
@ -60,7 +70,9 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: Termi
|
|||
const hadAttachmentRef = useRef(false);
|
||||
const canRestoreSession = terminalTarget?.kind !== "reviewer" && session?.status === "terminated";
|
||||
|
||||
const handleReady = useCallback((handle: AttachableTerminal) => setTerminal(handle), []);
|
||||
const handleReady = useCallback((handle: AttachableTerminal) => {
|
||||
setTerminal(handle);
|
||||
}, []);
|
||||
const handleInitError = useCallback((err: unknown) => {
|
||||
console.error("xterm failed to initialize", err);
|
||||
setInitFailed(true);
|
||||
|
|
@ -97,7 +109,7 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: Termi
|
|||
}
|
||||
hadAttachmentRef.current = true;
|
||||
return attach(terminal);
|
||||
}, [terminal, handleId, attach]);
|
||||
}, [terminal, handleId, attach, attachSession?.id]);
|
||||
|
||||
if (initFailed) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
//
|
||||
// Design rules (the reason this component exists):
|
||||
// - The mount effect is dependency-free: the terminal instance is created once
|
||||
// per mount and NEVER torn down because a callback identity or session
|
||||
// changed. Session switching is the owner's job (re-point the mux, clear the
|
||||
// screen) — see TerminalPane.
|
||||
// per mount and NEVER torn down because a callback identity changed.
|
||||
// TerminalPane chooses the mount lifetime; it keys mounts by terminal handle
|
||||
// so session switches get a clean surface, while same-handle reconnects reuse
|
||||
// the mounted renderer.
|
||||
// - Nothing writes into the buffer at mount. Status/empty-state belongs to DOM
|
||||
// chrome around the terminal, not inside it. Writing before layout settles
|
||||
// is what crashed xterm's Viewport (`dimensions` of a zero-sized renderer).
|
||||
|
|
@ -26,7 +27,7 @@ import { SearchAddon } from "@xterm/addon-search";
|
|||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import type { AttachableTerminal } from "../hooks/useTerminalSession";
|
||||
import type { AttachableTerminal, TerminalUserInputSource } from "../hooks/useTerminalSession";
|
||||
import { buildTerminalThemes } from "../lib/terminal-themes";
|
||||
import type { Theme } from "../stores/ui-store";
|
||||
|
||||
|
|
@ -68,12 +69,19 @@ const terminalThemes = buildTerminalThemes();
|
|||
|
||||
// Erase scrollback (3J) + display (2J) and home the cursor — yyork's
|
||||
// terminalResetSequence. Deliberately NOT term.reset(): every pane PTY is a
|
||||
// fresh per-client `zellij attach` whose handshake re-asserts the DEC private
|
||||
// modes (SGR mouse tracking, alt screen) anyway, but a full RIS would drop
|
||||
// them for the window until that handshake arrives — a flash where wheel
|
||||
// events stop reaching zellij. The clear only wipes pixels; modes stay up.
|
||||
// fresh per-client `zellij attach` whose handshake re-asserts terminal modes
|
||||
// anyway, but a full RIS would drop them for the window until that handshake
|
||||
// arrives. The clear only wipes pixels; modes stay up.
|
||||
const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H";
|
||||
|
||||
function preparePastedText(text: string): string {
|
||||
return text.replace(/\r?\n/g, "\r");
|
||||
}
|
||||
|
||||
function bracketPastedText(text: string, bracketedPasteMode: boolean): string {
|
||||
return bracketedPasteMode ? `\x1b[200~${text}\x1b[201~` : text;
|
||||
}
|
||||
|
||||
export function XtermTerminal(props: XtermTerminalProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
|
|
@ -227,6 +235,26 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
// misses them. Listen on window directly as a session-long recovery path.
|
||||
window.addEventListener("resize", fitTerminal);
|
||||
|
||||
const userInputListeners = new Set<(data: string, source: TerminalUserInputSource) => void>();
|
||||
const emitUserInput = (data: string, source: TerminalUserInputSource) => {
|
||||
if (data.length === 0) return;
|
||||
userInputListeners.forEach((listener) => listener(data, source));
|
||||
};
|
||||
const keyInput = term.onKey(({ key }) => emitUserInput(key, "keyboard"));
|
||||
const pasteInput = (event: ClipboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
const prepared = preparePastedText(text);
|
||||
const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true;
|
||||
emitUserInput(bracketPastedText(prepared, bracketed), "paste");
|
||||
};
|
||||
const compositionInput = (event: CompositionEvent) => {
|
||||
emitUserInput(event.data, "composition");
|
||||
};
|
||||
host.addEventListener("paste", pasteInput, true);
|
||||
host.addEventListener("compositionend", compositionInput, true);
|
||||
|
||||
// Live cols/rows getters: the owner reads the current grid at attach time,
|
||||
// not a snapshot taken at ready time (the first fit may not have run yet).
|
||||
const handle: AttachableTerminal = {
|
||||
|
|
@ -239,7 +267,10 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
write: (data) => term.write(data),
|
||||
writeln: (line) => term.writeln(line),
|
||||
clear: () => term.write(CLEAR_SEQUENCE),
|
||||
onData: (listener) => term.onData(listener),
|
||||
onUserInput: (listener) => {
|
||||
userInputListeners.add(listener);
|
||||
return { dispose: () => userInputListeners.delete(listener) };
|
||||
},
|
||||
onResize: (listener) => term.onResize(listener),
|
||||
};
|
||||
callbacksRef.current.onReady?.(handle);
|
||||
|
|
@ -251,6 +282,10 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
observer.disconnect();
|
||||
stabilizer.dispose();
|
||||
window.removeEventListener("resize", fitTerminal);
|
||||
host.removeEventListener("paste", pasteInput, true);
|
||||
host.removeEventListener("compositionend", compositionInput, true);
|
||||
keyInput.dispose();
|
||||
userInputListeners.clear();
|
||||
try {
|
||||
term.dispose();
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ type FakeMux = {
|
|||
opens: Array<[string, number, number]>;
|
||||
resizes: Array<[string, number, number]>;
|
||||
inputs: Array<[string, string]>;
|
||||
closes: string[];
|
||||
events: string[];
|
||||
disposed: boolean;
|
||||
emitData(id: string, text: string): void;
|
||||
emitOpened(id: string): void;
|
||||
|
|
@ -51,12 +53,17 @@ function createFakeMux(): FakeMux {
|
|||
opens: [],
|
||||
resizes: [],
|
||||
inputs: [],
|
||||
closes: [],
|
||||
events: [],
|
||||
disposed: false,
|
||||
mux: {
|
||||
open: (id, cols, rows) => fake.opens.push([id, cols, rows]),
|
||||
sendInput: (id, input) => fake.inputs.push([id, input]),
|
||||
resize: (id, cols, rows) => fake.resizes.push([id, cols, rows]),
|
||||
close: () => undefined,
|
||||
close: (id) => {
|
||||
fake.closes.push(id);
|
||||
fake.events.push(`close:${id}`);
|
||||
},
|
||||
onData: (id, listener) => subscribe(data, id, listener),
|
||||
onExit: (id, listener) => subscribe(exit, id, listener),
|
||||
onOpened: (id, listener) => subscribe(opened, id, listener),
|
||||
|
|
@ -67,6 +74,7 @@ function createFakeMux(): FakeMux {
|
|||
},
|
||||
dispose: () => {
|
||||
fake.disposed = true;
|
||||
fake.events.push("dispose");
|
||||
},
|
||||
},
|
||||
emitData: (id, text) => data.get(id)?.forEach((listener) => listener(new TextEncoder().encode(text))),
|
||||
|
|
@ -82,11 +90,12 @@ type FakeTerminal = AttachableTerminal & {
|
|||
lines: string[];
|
||||
clears: number;
|
||||
typeKeys(data: string): void;
|
||||
paste(data: string): void;
|
||||
emitResize(cols: number, rows: number): void;
|
||||
};
|
||||
|
||||
function createFakeTerminal(): FakeTerminal {
|
||||
const dataListeners = new Set<(data: string) => void>();
|
||||
const inputListeners = new Set<Parameters<AttachableTerminal["onUserInput"]>[0]>();
|
||||
const resizeListeners = new Set<(size: { cols: number; rows: number }) => void>();
|
||||
const terminal: FakeTerminal = {
|
||||
cols: 80,
|
||||
|
|
@ -98,15 +107,16 @@ function createFakeTerminal(): FakeTerminal {
|
|||
clear: () => {
|
||||
terminal.clears += 1;
|
||||
},
|
||||
onData: (listener) => {
|
||||
dataListeners.add(listener);
|
||||
return { dispose: () => dataListeners.delete(listener) };
|
||||
onUserInput: (listener) => {
|
||||
inputListeners.add(listener);
|
||||
return { dispose: () => inputListeners.delete(listener) };
|
||||
},
|
||||
onResize: (listener) => {
|
||||
resizeListeners.add(listener);
|
||||
return { dispose: () => resizeListeners.delete(listener) };
|
||||
},
|
||||
typeKeys: (data) => dataListeners.forEach((listener) => listener(data)),
|
||||
typeKeys: (data) => inputListeners.forEach((listener) => listener(data, "keyboard")),
|
||||
paste: (data) => inputListeners.forEach((listener) => listener(data, "paste")),
|
||||
emitResize: (cols, rows) => resizeListeners.forEach((listener) => listener({ cols, rows })),
|
||||
};
|
||||
return terminal;
|
||||
|
|
@ -163,10 +173,16 @@ describe("useTerminalSession", () => {
|
|||
|
||||
it("forwards PTY output, keystrokes, and resizes across the attachment", () => {
|
||||
const { terminal, muxes } = setup();
|
||||
act(() => muxes[0].emitOpened("handle-1"));
|
||||
act(() => muxes[0].emitData("handle-1", "hello"));
|
||||
expect(terminal.lines).toContain("hello");
|
||||
terminal.typeKeys("ls\r");
|
||||
expect(muxes[0].inputs).toEqual([["handle-1", "ls\r"]]);
|
||||
terminal.paste("echo pasted\r");
|
||||
expect(muxes[0].inputs).toEqual([
|
||||
["handle-1", "ls\r"],
|
||||
["handle-1", "echo pasted\r"],
|
||||
]);
|
||||
terminal.emitResize(120, 40);
|
||||
act(() => void vi.advanceTimersByTime(100));
|
||||
expect(muxes[0].resizes).toContainEqual(["handle-1", 120, 40]);
|
||||
|
|
@ -191,6 +207,15 @@ describe("useTerminalSession", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("does not forward input until the server opens the current attachment", () => {
|
||||
const { terminal, muxes } = setup();
|
||||
terminal.typeKeys("too early");
|
||||
expect(muxes[0].inputs).toEqual([]);
|
||||
act(() => muxes[0].emitOpened("handle-1"));
|
||||
terminal.typeKeys("ready\r");
|
||||
expect(muxes[0].inputs).toEqual([["handle-1", "ready\r"]]);
|
||||
});
|
||||
|
||||
it("a new resize burst supersedes a pending re-assert", () => {
|
||||
const { terminal, muxes } = setup();
|
||||
const initialResizes = muxes[0].resizes.length;
|
||||
|
|
@ -238,6 +263,33 @@ describe("useTerminalSession", () => {
|
|||
expect(view.result.current.state).toBe("attached");
|
||||
});
|
||||
|
||||
it("ignores stale frames after a reconnect starts", () => {
|
||||
const { view, terminal, muxes } = setup();
|
||||
act(() => muxes[0].emitConnection("closed"));
|
||||
act(() => void vi.advanceTimersByTime(500));
|
||||
expect(muxes).toHaveLength(2);
|
||||
act(() => muxes[0].emitOpened("handle-1"));
|
||||
act(() => muxes[0].emitData("handle-1", "stale"));
|
||||
expect(view.result.current.state).toBe("reattaching");
|
||||
expect(terminal.lines).not.toContain("stale");
|
||||
act(() => muxes[1].emitOpened("handle-1"));
|
||||
expect(view.result.current.state).toBe("attached");
|
||||
});
|
||||
|
||||
it("hard-reconnects when the server never acknowledges open", () => {
|
||||
const { view, muxes } = setup();
|
||||
act(() => void vi.advanceTimersByTime(2_999));
|
||||
expect(muxes).toHaveLength(1);
|
||||
act(() => void vi.advanceTimersByTime(1));
|
||||
expect(view.result.current.state).toBe("reattaching");
|
||||
expect(muxes[0].disposed).toBe(true);
|
||||
act(() => void vi.advanceTimersByTime(500));
|
||||
expect(muxes).toHaveLength(2);
|
||||
expect(muxes[1].opens).toEqual([["handle-1", 80, 24]]);
|
||||
act(() => muxes[1].emitOpened("handle-1"));
|
||||
expect(view.result.current.state).toBe("attached");
|
||||
});
|
||||
|
||||
it("backs off between failed reconnect attempts", () => {
|
||||
const { muxes } = setup();
|
||||
act(() => muxes[0].emitConnection("closed"));
|
||||
|
|
@ -267,6 +319,8 @@ describe("useTerminalSession", () => {
|
|||
act(() => detach());
|
||||
expect(view.result.current.state).toBe("idle");
|
||||
expect(muxes[0].disposed).toBe(true);
|
||||
expect(muxes[0].closes).toEqual(["handle-1"]);
|
||||
expect(muxes[0].events).toEqual(["close:handle-1", "dispose"]);
|
||||
act(() => muxes[0].emitConnection("closed"));
|
||||
act(() => void vi.advanceTimersByTime(60_000));
|
||||
expect(muxes).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { workspaceQueryKey } from "./useWorkspaceQuery";
|
|||
* The slice of xterm's Terminal the attachment needs. Structural, so tests can
|
||||
* drive the hook with a tiny fake instead of a real xterm + DOM.
|
||||
*/
|
||||
export type TerminalUserInputSource = "keyboard" | "paste" | "composition";
|
||||
|
||||
export type AttachableTerminal = {
|
||||
cols: number;
|
||||
rows: number;
|
||||
|
|
@ -30,7 +32,7 @@ export type AttachableTerminal = {
|
|||
* with wheel scroll dead (see XtermTerminal's CLEAR_SEQUENCE).
|
||||
*/
|
||||
clear: () => void;
|
||||
onData: (listener: (data: string) => void) => { dispose: () => void };
|
||||
onUserInput: (listener: (data: string, source: TerminalUserInputSource) => void) => { dispose: () => void };
|
||||
onResize: (listener: (size: { cols: number; rows: number }) => void) => { dispose: () => void };
|
||||
};
|
||||
|
||||
|
|
@ -51,6 +53,7 @@ export type UseTerminalSessionOptions = {
|
|||
|
||||
const RETRY_BASE_MS = 500;
|
||||
const RETRY_MAX_MS = 8_000;
|
||||
const OPEN_TIMEOUT_MS = 3_000;
|
||||
// Trailing debounce on grid changes: a pane drag emits a burst of intermediate
|
||||
// sizes; the attached program should get one SIGWINCH when the drag settles,
|
||||
// not dozens (yyork's terminal-panel does the same at its socket layer).
|
||||
|
|
@ -88,9 +91,12 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
|||
handle: null as string | null,
|
||||
disposers: [] as Array<() => void>,
|
||||
retryTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
openTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
resizeTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
attempts: 0,
|
||||
firstAttach: true,
|
||||
generation: 0,
|
||||
inputReady: false,
|
||||
detached: true,
|
||||
});
|
||||
|
||||
|
|
@ -109,25 +115,53 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
|||
clearTimeout(r.retryTimer);
|
||||
r.retryTimer = null;
|
||||
}
|
||||
if (r.openTimer) {
|
||||
clearTimeout(r.openTimer);
|
||||
r.openTimer = null;
|
||||
}
|
||||
if (r.resizeTimer) {
|
||||
clearTimeout(r.resizeTimer);
|
||||
r.resizeTimer = null;
|
||||
}
|
||||
r.inputReady = false;
|
||||
if (r.mux && r.handle) {
|
||||
r.mux.close(r.handle);
|
||||
}
|
||||
r.disposers.forEach((dispose) => dispose());
|
||||
r.disposers = [];
|
||||
r.mux?.dispose();
|
||||
r.mux = null;
|
||||
}, []);
|
||||
|
||||
const isCurrentAttachment = useCallback((generation: number, handle: string, mux: TerminalMux) => {
|
||||
const r = runtime.current;
|
||||
return !r.detached && r.generation === generation && r.handle === handle && r.mux === mux;
|
||||
}, []);
|
||||
|
||||
const clearOpenTimer = useCallback((generation: number) => {
|
||||
const r = runtime.current;
|
||||
if (r.generation !== generation || !r.openTimer) return;
|
||||
clearTimeout(r.openTimer);
|
||||
r.openTimer = null;
|
||||
}, []);
|
||||
|
||||
const scheduleReattach = useCallback(() => {
|
||||
const r = runtime.current;
|
||||
if (r.detached || !r.terminal || !r.handle) return;
|
||||
if (r.detached || !r.terminal || !r.handle) {
|
||||
return;
|
||||
}
|
||||
// A socket dropping after the PTY ended (or errored) changes nothing.
|
||||
if (stateRef.current === "exited" || stateRef.current === "error") return;
|
||||
if (stateRef.current === "exited" || stateRef.current === "error") {
|
||||
return;
|
||||
}
|
||||
transition("reattaching");
|
||||
// Not ready → no timer; the daemonReady effect reconnects when it flips.
|
||||
if (!optionsRef.current.daemonReady) return;
|
||||
if (r.retryTimer) return;
|
||||
if (!optionsRef.current.daemonReady) {
|
||||
return;
|
||||
}
|
||||
if (r.retryTimer) {
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(RETRY_BASE_MS * 2 ** r.attempts, RETRY_MAX_MS);
|
||||
r.attempts += 1;
|
||||
r.retryTimer = setTimeout(() => {
|
||||
|
|
@ -139,45 +173,75 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
|||
const connect = useCallback(() => {
|
||||
const r = runtime.current;
|
||||
const { terminal, handle } = r;
|
||||
if (!terminal || !handle || r.detached) return;
|
||||
if (!terminal || !handle || r.detached) {
|
||||
return;
|
||||
}
|
||||
const generation = r.generation + 1;
|
||||
r.generation = generation;
|
||||
r.inputReady = false;
|
||||
teardownMux();
|
||||
|
||||
const mux = (optionsRef.current.createMux ?? defaultCreateMux)();
|
||||
r.mux = mux;
|
||||
|
||||
r.disposers.push(
|
||||
mux.onData(handle, (bytes) => terminal.write(bytes)),
|
||||
mux.onData(handle, (bytes) => {
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
terminal.write(bytes);
|
||||
}),
|
||||
mux.onOpened(handle, () => {
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
clearOpenTimer(generation);
|
||||
r.inputReady = true;
|
||||
r.attempts = 0;
|
||||
setError(undefined);
|
||||
transition("attached");
|
||||
}),
|
||||
mux.onExit(handle, () => {
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
clearOpenTimer(generation);
|
||||
r.inputReady = false;
|
||||
terminal.writeln("\r\n\x1b[2m[process exited]\x1b[0m");
|
||||
transition("exited");
|
||||
invalidateWorkspaces();
|
||||
}),
|
||||
mux.onError(handle, (message) => {
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
clearOpenTimer(generation);
|
||||
r.inputReady = false;
|
||||
terminal.writeln(`\r\n\x1b[2m[terminal error] ${message}\x1b[0m`);
|
||||
setError(message);
|
||||
transition("error");
|
||||
invalidateWorkspaces();
|
||||
}),
|
||||
mux.onConnectionChange((connectionState) => {
|
||||
if (connectionState === "closed") scheduleReattach();
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
if (connectionState === "closed") {
|
||||
clearOpenTimer(generation);
|
||||
r.inputReady = false;
|
||||
scheduleReattach();
|
||||
}
|
||||
}),
|
||||
);
|
||||
const input = terminal.onData((data) => mux.sendInput(handle, data));
|
||||
const input = terminal.onUserInput((data) => {
|
||||
if (!isCurrentAttachment(generation, handle, mux) || !r.inputReady) {
|
||||
return;
|
||||
}
|
||||
mux.sendInput(handle, data);
|
||||
});
|
||||
// xterm only fires onResize when the grid actually changed; the debounce
|
||||
// additionally collapses a drag's burst of changes into one PTY resize.
|
||||
// Each settled resize is re-asserted once (see RESIZE_REASSERT_MS); both
|
||||
// stages share resizeTimer so a new burst or teardown cancels either.
|
||||
const resize = terminal.onResize(({ cols, rows }) => {
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
if (r.resizeTimer) clearTimeout(r.resizeTimer);
|
||||
r.resizeTimer = setTimeout(() => {
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
mux.resize(handle, cols, rows);
|
||||
r.resizeTimer = setTimeout(() => {
|
||||
r.resizeTimer = null;
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
mux.resize(handle, cols, rows);
|
||||
}, RESIZE_REASSERT_MS);
|
||||
}, RESIZE_DEBOUNCE_MS);
|
||||
|
|
@ -200,7 +264,14 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
|||
|
||||
mux.open(handle, terminal.cols, terminal.rows);
|
||||
mux.resize(handle, terminal.cols, terminal.rows);
|
||||
}, [invalidateWorkspaces, scheduleReattach, teardownMux, transition]);
|
||||
r.openTimer = setTimeout(() => {
|
||||
if (!isCurrentAttachment(generation, handle, mux)) return;
|
||||
r.openTimer = null;
|
||||
transition("reattaching");
|
||||
teardownMux();
|
||||
scheduleReattach();
|
||||
}, OPEN_TIMEOUT_MS);
|
||||
}, [clearOpenTimer, invalidateWorkspaces, isCurrentAttachment, scheduleReattach, teardownMux, transition]);
|
||||
connectRef.current = connect;
|
||||
|
||||
/**
|
||||
|
|
@ -224,10 +295,12 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
|||
transition("idle");
|
||||
}
|
||||
return () => {
|
||||
r.generation += 1;
|
||||
r.detached = true;
|
||||
teardownMux();
|
||||
r.terminal = null;
|
||||
r.handle = null;
|
||||
r.inputReady = false;
|
||||
setError(undefined);
|
||||
transition("idle");
|
||||
};
|
||||
|
|
@ -250,7 +323,10 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
|
|||
// forgot to call detach.
|
||||
useEffect(
|
||||
() => () => {
|
||||
runtime.current.detached = true;
|
||||
const r = runtime.current;
|
||||
r.generation += 1;
|
||||
r.detached = true;
|
||||
r.inputReady = false;
|
||||
teardownMux();
|
||||
},
|
||||
[teardownMux],
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ type ConnectionListener = (state: MuxConnectionState) => void;
|
|||
export type TerminalMux = {
|
||||
/** Open a PTY pane for the given runtime/session id at an initial size. */
|
||||
open: (id: string, cols: number, rows: number) => void;
|
||||
/** Forward a keystroke string (xterm `onData`) to the pane. */
|
||||
/** Forward user-originated keyboard/paste data to the pane. */
|
||||
sendInput: (id: string, input: string) => void;
|
||||
resize: (id: string, cols: number, rows: number) => void;
|
||||
close: (id: string) => void;
|
||||
|
|
@ -166,8 +166,12 @@ export function createTerminalMux(url: string, WebSocketImpl: typeof WebSocket =
|
|||
setConnectionState("open");
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => setConnectionState("closed"));
|
||||
socket.addEventListener("error", () => setConnectionState("closed"));
|
||||
socket.addEventListener("close", () => {
|
||||
setConnectionState("closed");
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
setConnectionState("closed");
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event: MessageEvent) => {
|
||||
if (typeof event.data !== "string") return;
|
||||
|
|
@ -214,10 +218,19 @@ export function createTerminalMux(url: string, WebSocketImpl: typeof WebSocket =
|
|||
};
|
||||
|
||||
return {
|
||||
open: (id, cols, rows) => send(openFrame(id, cols, rows)),
|
||||
sendInput: (id, input) => send(dataFrame(id, encoder.encode(input))),
|
||||
resize: (id, cols, rows) => send(resizeFrame(id, cols, rows)),
|
||||
close: (id) => send(closeFrame(id)),
|
||||
open: (id, cols, rows) => {
|
||||
send(openFrame(id, cols, rows));
|
||||
},
|
||||
sendInput: (id, input) => {
|
||||
const bytes = encoder.encode(input);
|
||||
send(dataFrame(id, bytes));
|
||||
},
|
||||
resize: (id, cols, rows) => {
|
||||
send(resizeFrame(id, cols, rows));
|
||||
},
|
||||
close: (id) => {
|
||||
send(closeFrame(id));
|
||||
},
|
||||
onData: (id, listener) => subscribeById(dataListeners, id, listener),
|
||||
onExit: (id, listener) => subscribeById(exitListeners, id, listener),
|
||||
onOpened: (id, listener) => subscribeById(openedListeners, id, listener),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ const pr = (overrides: Partial<PullRequestFacts> & { number: number; state: PRSt
|
|||
describe("toSessionStatus", () => {
|
||||
it("passes through a known status", () => {
|
||||
expect(toSessionStatus("mergeable")).toBe("mergeable");
|
||||
expect(toSessionStatus("no_signal")).toBe("no_signal");
|
||||
});
|
||||
|
||||
it("overrides to terminated when the session is terminated", () => {
|
||||
|
|
@ -73,6 +74,7 @@ describe("workerDisplayStatus", () => {
|
|||
["changes_requested", "needs_you"],
|
||||
["review_pending", "needs_you"],
|
||||
["ci_failed", "ci_failed"],
|
||||
["no_signal", "no_signal"],
|
||||
["approved", "mergeable"],
|
||||
["mergeable", "mergeable"],
|
||||
["merged", "done"],
|
||||
|
|
@ -132,6 +134,10 @@ describe("sessionNeedsAttention", () => {
|
|||
expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats no_signal as needing attention", () => {
|
||||
expect(sessionNeedsAttention(sessionWith({ status: "no_signal" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for statuses that don't need the user", () => {
|
||||
expect(sessionNeedsAttention(sessionWith({ status: "working" }))).toBe(false);
|
||||
expect(sessionNeedsAttention(sessionWith({ status: "mergeable" }))).toBe(false);
|
||||
|
|
@ -143,6 +149,7 @@ describe("workerStatusPulses", () => {
|
|||
expect(workerStatusPulses("working")).toBe(true);
|
||||
expect(workerStatusPulses("needs_you")).toBe(true);
|
||||
expect(workerStatusPulses("mergeable")).toBe(false);
|
||||
expect(workerStatusPulses("no_signal")).toBe(false);
|
||||
expect(workerStatusPulses("done")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -199,6 +206,7 @@ describe("attentionZone", () => {
|
|||
["approved", "merge"],
|
||||
["needs_input", "action"],
|
||||
["ci_failed", "action"],
|
||||
["no_signal", "action"],
|
||||
["changes_requested", "action"],
|
||||
["review_pending", "pending"],
|
||||
["pr_open", "pending"],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type SessionStatus =
|
|||
| "mergeable"
|
||||
| "merged"
|
||||
| "needs_input"
|
||||
| "no_signal"
|
||||
| "idle"
|
||||
| "terminated";
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ const sessionStatuses = new Set<SessionStatus>([
|
|||
"mergeable",
|
||||
"merged",
|
||||
"needs_input",
|
||||
"no_signal",
|
||||
"idle",
|
||||
"terminated",
|
||||
]);
|
||||
|
|
@ -119,7 +121,7 @@ export type WorkspaceSession = {
|
|||
};
|
||||
|
||||
/** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */
|
||||
export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "done";
|
||||
export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "no_signal" | "done";
|
||||
|
||||
export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplayStatus {
|
||||
if (session.displayStatus) return session.displayStatus;
|
||||
|
|
@ -130,6 +132,8 @@ export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplaySta
|
|||
return "needs_you";
|
||||
case "ci_failed":
|
||||
return "ci_failed";
|
||||
case "no_signal":
|
||||
return "no_signal";
|
||||
case "approved":
|
||||
case "mergeable":
|
||||
return "mergeable";
|
||||
|
|
@ -194,6 +198,7 @@ export function sessionIsActive(session: WorkspaceSession): boolean {
|
|||
export function sessionNeedsAttention(session: WorkspaceSession): boolean {
|
||||
return (
|
||||
session.status === "needs_input" ||
|
||||
session.status === "no_signal" ||
|
||||
session.status === "changes_requested" ||
|
||||
session.status === "review_pending" ||
|
||||
session.status === "ci_failed"
|
||||
|
|
@ -205,6 +210,7 @@ export const workerStatusLabel: Record<WorkerDisplayStatus, string> = {
|
|||
needs_you: "needs you",
|
||||
mergeable: "mergeable",
|
||||
ci_failed: "ci failed",
|
||||
no_signal: "no signal",
|
||||
done: "done",
|
||||
};
|
||||
|
||||
|
|
@ -246,6 +252,7 @@ export function attentionZone(session: WorkspaceSession): AttentionZone {
|
|||
// Agent waiting on a human (respond) or a problem to investigate (review);
|
||||
// agent-orchestrator collapses these into one "action" zone by default.
|
||||
case "needs_input":
|
||||
case "no_signal":
|
||||
case "ci_failed":
|
||||
case "changes_requested":
|
||||
return "action";
|
||||
|
|
|
|||
Loading…
Reference in New Issue