Skip to content

Commit 2f0cb80

Browse files
committed
client: restore host terminal on session exit
1 parent 355a119 commit 2f0cb80

6 files changed

Lines changed: 120 additions & 38 deletions

File tree

client/attach.go

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,6 @@ func (c *Client) RunAttach(name string, command []string, dk DetachKey, forwardE
117117
}
118118
}
119119

120-
// Enter raw mode after writing the state dump so OPOST translates
121-
// \n in the VT dump to \r\n correctly while in cooked mode.
122120
oldState, err := term.MakeRaw(fd)
123121
if err != nil {
124122
return fmt.Errorf("set raw mode: %w", err)
@@ -201,11 +199,18 @@ func (c *Client) RunAttach(name string, command []string, dk DetachKey, forwardE
201199
}
202200
}()
203201

202+
if attached.Created && len(command) > 0 && len(attached.ScreenDump) > 0 {
203+
if _, err := os.Stdout.Write(attached.ScreenDump); err != nil {
204+
return fmt.Errorf("write state dump: %w", err)
205+
}
206+
}
207+
204208
handleMsg := func(msg protocol.Message) error {
205209
switch m := msg.(type) {
206210
case *protocol.Output:
207211
os.Stdout.Write(m.Data)
208212
case *protocol.Exited:
213+
restoreHostTerminal(fd, oldState, "[hauntty] session exited\n")
209214
return &ExitError{Code: int(m.ExitCode)}
210215
case *protocol.Error:
211216
term.Restore(fd, oldState)
@@ -222,36 +227,40 @@ func (c *Client) RunAttach(name string, command []string, dk DetachKey, forwardE
222227
if err != nil {
223228
close(done)
224229
if err == io.EOF || isConnClosed(err) {
225-
// Use 1047 (not 1049) to exit alt screen: 1047
226-
// just switches the buffer without restoring the
227-
// saved cursor, so session content on the primary
228-
// screen stays intact. No-op when already on primary.
229-
//
230-
// Reset modes, show cursor, pop kitty keyboard,
231-
// reset SGR, erase from cursor to end of screen.
232-
// Session content above the cursor is preserved.
233-
os.Stdout.Write([]byte(
234-
"\x1b[?1047;1;1000;1002;1003;1006;1004;2004;2048;2026l" +
235-
"\x1b[?25h" +
236-
"\x1b[<u" +
237-
"\x1b[0m" +
238-
"\x1b[J"))
239-
drainStdin(fd, 20*time.Millisecond)
240-
term.Restore(fd, oldState)
241-
fmt.Fprintf(os.Stderr, "[hauntty] detached\n")
230+
restoreHostTerminal(fd, oldState, "[hauntty] detached\n")
242231
return nil
243232
}
244233
term.Restore(fd, oldState)
245234
return fmt.Errorf("read message: %w", err)
246235
}
247236
if err := handleMsg(msg); err != nil {
248237
close(done)
249-
term.Restore(fd, oldState)
250238
return err
251239
}
252240
}
253241
}
254242

243+
func restoreHostTerminal(fd int, oldState *term.State, message string) {
244+
// Use 1047 (not 1049) to exit alt screen: 1047 just switches the
245+
// buffer without restoring the saved cursor, so session content on
246+
// the primary screen stays intact. No-op when already on primary.
247+
//
248+
// Reset modes, show cursor, pop kitty keyboard, reset SGR, erase
249+
// from cursor to end of screen. Session content above the cursor is
250+
// preserved.
251+
os.Stdout.Write([]byte(
252+
"\x1b[?1047;1;1000;1002;1003;1006;1004;2004;2048;2026l" +
253+
"\x1b[?25h" +
254+
"\x1b[<u" +
255+
"\x1b[0m" +
256+
"\x1b[J"))
257+
drainStdin(fd, 20*time.Millisecond)
258+
term.Restore(fd, oldState)
259+
if message != "" {
260+
fmt.Fprint(os.Stderr, message)
261+
}
262+
}
263+
255264
// readCursorRow reads the DSR response (\x1b[{row};{col}R) from fd
256265
// and returns the cursor row. Must be called in raw mode after sending
257266
// \x1b[6n. Returns fallback if the response cannot be parsed.

cmd/ht/e2e_test/automation_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ func TestDumpDeadSessionPreservesFormats(t *testing.T) {
160160
sh.Type("$HT_BIN attach dead-dump-formats -- /bin/sh -c \"printf '\\033[31mred\\033[0m\\nplain\\n'; sleep 30\"\n")
161161
sh.WaitFor("red")
162162
sh.WaitFor("plain")
163+
sh.WaitStable(250*time.Millisecond, termtest.WaitTimeout(2*time.Second))
163164
sh.Key(libghostty.KeyCode(']'), libghostty.ModCtrl)
164-
sh.WaitFor("detached")
165165
e.waitHostPrompt(sh)
166166

167167
kill := e.run("kill", "dead-dump-formats")
@@ -193,6 +193,7 @@ func TestDumpDeadSessionPreservesJoinFlag(t *testing.T) {
193193
e.waitHostPrompt(sh)
194194
sh.Type("$HT_BIN attach dead-dump-join -- /bin/sh -c \"printf 'aaaaaaaaaaaaaaaaaaaabbbbbbbbbb\\n'; sleep 30\"\n")
195195
sh.WaitFor("bbbb")
196+
sh.WaitStable(250*time.Millisecond, termtest.WaitTimeout(2*time.Second))
196197
sh.Key(libghostty.KeyCode(']'), libghostty.ModCtrl)
197198
e.waitHostPrompt(sh)
198199

cmd/ht/e2e_test/lifecycle_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,53 @@ func TestRestoreDeadSession(t *testing.T) {
370370
e.waitHostPrompt(restoreSh)
371371
}
372372

373+
func TestRestoreDeadSessionAfterHostOutput(t *testing.T) {
374+
cfg := config.Default()
375+
cfg.Client.DetachKeybind = "ctrl+]"
376+
cfg.Daemon.StatePersistence = true
377+
e := setup(t, cfg)
378+
379+
daemon := e.term([]string{htBin, "daemon"})
380+
daemon.WaitFor("daemon listening")
381+
382+
sh := e.term([]string{"/bin/sh"}, termtest.WithEnv("PS1=$ ", "SHELL=/bin/sh"))
383+
e.waitHostPrompt(sh)
384+
sh.Type("$HT_BIN attach restore-host-output\n")
385+
sh.WaitFor("created session")
386+
e.waitAttachedPrompt(sh)
387+
388+
sh.Type("echo restore-marker\n")
389+
sh.WaitFor("restore-marker")
390+
sh.WaitStable(250*time.Millisecond, termtest.WaitTimeout(2*time.Second))
391+
392+
sh.Key(libghostty.KeyCode(']'), libghostty.ModCtrl)
393+
sh.WaitFor("detached")
394+
e.waitHostPrompt(sh)
395+
396+
kill := e.run("kill", "restore-host-output")
397+
kill.Assert(t, icmd.Expected{ExitCode: 0})
398+
time.Sleep(500 * time.Millisecond)
399+
400+
dump := e.run("dump", "restore-host-output")
401+
dump.Assert(t, icmd.Expected{ExitCode: 0})
402+
403+
restoreSh := e.term([]string{"/bin/sh"}, termtest.WithEnv("PS1=$ ", "SHELL=/bin/sh"))
404+
e.waitHostPrompt(restoreSh)
405+
restoreSh.Type("echo host-before\n")
406+
restoreSh.WaitFor("host-before")
407+
restoreSh.Type("$HT_BIN restore restore-host-output\n")
408+
restoreSh.WaitFor("attached to session")
409+
restoreSh.WaitFor("restore-marker")
410+
e.waitAttachedPrompt(restoreSh)
411+
412+
restoreSh.Type("echo restored-ok\n")
413+
restoreSh.WaitFor("restored-ok")
414+
415+
restoreSh.Key(libghostty.KeyCode(']'), libghostty.ModCtrl)
416+
restoreSh.WaitFor("detached")
417+
e.waitHostPrompt(restoreSh)
418+
}
419+
373420
func TestRestoreRunningSessionFails(t *testing.T) {
374421
cfg := config.Default()
375422
cfg.Daemon.AutoExit = true

cmd/ht/e2e_test/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import (
1818

1919
var (
2020
htBin string
21-
hostPromptRE = regexp.MustCompile(`\$ ?$`)
21+
hostPromptRE = regexp.MustCompile(`^\$ ?$`)
2222
)
2323

2424
type testEnv struct {

cmd/ht/e2e_test/terminal_safety_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ func TestDetachFailurePathHostCleanup(t *testing.T) {
295295
kill := e.run("kill", "fail-cleanup")
296296
kill.Assert(t, icmd.Expected{ExitCode: 0, Out: "killed session \"fail-cleanup\"\n"})
297297

298+
sh.WaitFor("session exited")
298299
e.waitHostPrompt(sh)
299300
sh.Type("echo failure-cleanup-ok\n")
300301
sh.WaitFor("failure-cleanup-ok")

internal/daemon/session.go

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ var feedPool = sync.Pool{
2424
},
2525
}
2626

27+
type feedItem struct {
28+
data *[]byte
29+
seq uint64
30+
}
31+
2732
// All fields are owned exclusively by the session's run loop.
2833
type sessionClient struct {
2934
id string
@@ -95,13 +100,14 @@ type Session struct {
95100
ptmx *os.File
96101
cmd *exec.Cmd
97102
term *libghostty.Terminal
98-
feedCh chan *[]byte
103+
feedCh chan feedItem
99104
tempDir string
100105

101-
actions chan any
102-
ptyOut chan []byte
103-
done chan struct{}
104-
exitCode int32
106+
actions chan any
107+
ptyOut chan []byte
108+
done chan struct{}
109+
exitCode int32
110+
feedApplied atomic.Uint64
105111

106112
// sizeVal packs cols|rows as (cols<<16)|rows for lock-free reads.
107113
sizeVal atomic.Uint32
@@ -191,7 +197,7 @@ func newSession(ctx context.Context, name string, command []string, env []string
191197
ptmx: ptmx,
192198
cmd: cmd,
193199
term: term,
194-
feedCh: make(chan *[]byte, 64),
200+
feedCh: make(chan feedItem, 64),
195201
tempDir: tempDir,
196202
actions: make(chan any, 16),
197203
ptyOut: make(chan []byte, 64),
@@ -270,7 +276,7 @@ func restoreSession(ctx context.Context, name string, command []string, env []st
270276
ptmx: ptmx,
271277
cmd: cmd,
272278
term: term,
273-
feedCh: make(chan *[]byte, 64),
279+
feedCh: make(chan feedItem, 64),
274280
tempDir: tempDir,
275281
actions: make(chan any, 16),
276282
ptyOut: make(chan []byte, 64),
@@ -287,12 +293,20 @@ func restoreSession(ctx context.Context, name string, command []string, env []st
287293
}
288294

289295
func (s *Session) feedLoop(ctx context.Context) {
290-
for bp := range s.feedCh {
291-
if err := s.term.Feed(ctx, *bp); err != nil {
296+
for item := range s.feedCh {
297+
if err := s.term.Feed(ctx, *item.data); err != nil {
292298
slog.Debug("wasm feed error", "session", s.Name, "err", err)
293299
}
294-
*bp = (*bp)[:cap(*bp)]
295-
feedPool.Put(bp)
300+
s.feedApplied.Store(item.seq)
301+
*item.data = (*item.data)[:cap(*item.data)]
302+
feedPool.Put(item.data)
303+
}
304+
}
305+
306+
// waitFeedApplied blocks until feedLoop has applied every PTY chunk up to target.
307+
func (s *Session) waitFeedApplied(target uint64) {
308+
for s.feedApplied.Load() < target {
309+
time.Sleep(100 * time.Microsecond)
296310
}
297311
}
298312

@@ -331,16 +345,19 @@ func (s *Session) run() {
331345
// pendingFeed holds data waiting to be sent to feedCh. While
332346
// non-nil, we stop reading ptyOut (backpressure) but keep
333347
// processing actions so detach/kick/list don't stall.
334-
var pendingFeed *[]byte
348+
var pendingFeed *feedItem
349+
var nextFeedSeq uint64
335350

336351
for {
337352
// Nil-channel trick: only one of ptyCh/feedSend is active
338353
// at a time. When pendingFeed is nil, read ptyOut. When
339354
// non-nil, send to feedCh. Actions are always processed.
340355
var ptyCh <-chan []byte
341-
var feedSend chan<- *[]byte
356+
var feedSend chan<- feedItem
357+
var feedItemToSend feedItem
342358
if pendingFeed != nil {
343359
feedSend = s.feedCh
360+
feedItemToSend = *pendingFeed
344361
} else {
345362
ptyCh = s.ptyOut
346363
}
@@ -373,9 +390,10 @@ func (s *Session) run() {
373390
d := (*bp)[:len(data)]
374391
copy(d, data)
375392
*bp = d
376-
pendingFeed = bp
393+
nextFeedSeq++
394+
pendingFeed = &feedItem{data: bp, seq: nextFeedSeq}
377395

378-
case feedSend <- pendingFeed:
396+
case feedSend <- feedItemToSend:
379397
pendingFeed = nil
380398

381399
case action := <-s.actions:
@@ -384,6 +402,12 @@ func (s *Session) run() {
384402
if !a.readOnly {
385403
s.resizeForPending(clients, a.cols, a.rows, a.xpixel, a.ypixel)
386404
}
405+
if pendingFeed != nil {
406+
s.feedCh <- *pendingFeed
407+
pendingFeed = nil
408+
}
409+
// Attach dumps must reflect every PTY chunk we've already accepted.
410+
s.waitFeedApplied(nextFeedSeq)
387411

388412
dump, err := s.term.DumpScreen(s.ctx, libghostty.DumpVTFull)
389413
if err != nil {
@@ -485,7 +509,7 @@ func (s *Session) run() {
485509
// Clients see connection close (EOF), not Exited — this is
486510
// the kill/shutdown path.
487511
if pendingFeed != nil {
488-
feedPool.Put(pendingFeed)
512+
feedPool.Put(pendingFeed.data)
489513
pendingFeed = nil
490514
}
491515
close(s.feedCh)

0 commit comments

Comments
 (0)