Merge pull request #90 from bnema/fix/remote-reconnect-theme
fix(client): preserve terminal theme across reconnects
2 files · +216 -22
M internal/usecase/client/client.go +56 -20
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "log/slog" | |
| 17 | 17 | "os" | |
| 18 | 18 | "strings" | |
| 19 | + | "sync" | |
| 19 | 20 | "sync/atomic" | |
| 20 | 21 | "time" | |
| 21 | 22 | ||
| @@ -49,6 +50,34 @@ type DetachedError struct { | |||
| 49 | 50 | ||
| 50 | 51 | func (e *DetachedError) Error() string { return "vev: " + e.Text } | |
| 51 | 52 | ||
| 53 | + | // terminalThemeState retains the latest terminal-reported colors across | |
| 54 | + | // transport attempts so a replacement attachment can restore the daemon theme | |
| 55 | + | // without relying on another OSC response. | |
| 56 | + | type terminalThemeState struct { | |
| 57 | + | mu sync.Mutex | |
| 58 | + | theme ports.Theme | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (s *terminalThemeState) setTrueColor(enabled bool) { | |
| 62 | + | s.mu.Lock() | |
| 63 | + | s.theme.TrueColor = enabled | |
| 64 | + | s.mu.Unlock() | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme { | |
| 68 | + | s.mu.Lock() | |
| 69 | + | defer s.mu.Unlock() | |
| 70 | + | update(&s.theme) | |
| 71 | + | return s.theme | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | |
| 75 | + | s.mu.Lock() | |
| 76 | + | defer s.mu.Unlock() | |
| 77 | + | theme := s.theme | |
| 78 | + | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown | |
| 79 | + | } | |
| 80 | + | ||
| 52 | 81 | // milestones tracks lifecycle progress so a failure can report which stages | |
| 53 | 82 | // were never reached — the key diagnostic when an attach dies early. | |
| 54 | 83 | type milestones struct { | |
| @@ -187,6 +216,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 187 | 216 | backoff := defaultReconnectBackoff.initial | |
| 188 | 217 | showingStatus := false | |
| 189 | 218 | var reconnectToastRect domain.Rect | |
| 219 | + | themeState := &terminalThemeState{} | |
| 190 | 220 | statusStage := reconnectStageOfflineRetrying | |
| 191 | 221 | redrawRemoteStatus := func(size domain.Size) { | |
| 192 | 222 | if !rawEntered || !remote { | |
| @@ -270,7 +300,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 270 | 300 | if reporter, ok := transport.(ports.LinkStateReporter); ok { | |
| 271 | 301 | linkEvents = reporter.LinkEvents() | |
| 272 | 302 | } | |
| 273 | - | result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer) | |
| 303 | + | result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, themeState, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer) | |
| 274 | 304 | if result.welcomed { | |
| 275 | 305 | backoff = defaultReconnectBackoff.initial | |
| 276 | 306 | } | |
| @@ -422,7 +452,7 @@ func requestedOutputWindow(transport ports.Transport) uint8 { | |||
| 422 | 452 | return 8 | |
| 423 | 453 | } | |
| 424 | 454 | ||
| 425 | - | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult { | |
| 455 | + | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, themeState *terminalThemeState, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult { | |
| 426 | 456 | var observer ports.RuntimeObserver | |
| 427 | 457 | if len(observers) != 0 { | |
| 428 | 458 | observer = observers[0] | |
| @@ -439,6 +469,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 439 | 469 | termEnv := os.Getenv("TERM") | |
| 440 | 470 | colorTerm := os.Getenv("COLORTERM") | |
| 441 | 471 | trueColor := DetectTrueColor(termEnv, colorTerm) | |
| 472 | + | themeState.setTrueColor(trueColor) | |
| 442 | 473 | hello := ports.Hello{ | |
| 443 | 474 | Version: ports.ProtocolVersion, | |
| 444 | 475 | Intent: intent, | |
| @@ -497,6 +528,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 497 | 528 | return welcomedResult(err) | |
| 498 | 529 | } | |
| 499 | 530 | clearStatus() | |
| 531 | + | reportedTheme, restoreTheme := themeState.reportedTheme() | |
| 500 | 532 | ||
| 501 | 533 | // 4. Derive a cancellable context so the pumps always unwind when the | |
| 502 | 534 | // loop returns. cancel runs first (LIFO): it signals the pumps; the | |
| @@ -505,6 +537,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 505 | 537 | defer cancel() | |
| 506 | 538 | ||
| 507 | 539 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 540 | + | if restoreTheme { | |
| 541 | + | sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(reportedTheme)} | |
| 542 | + | } | |
| 508 | 543 | ackQueue := newCumulativeAckQueue() | |
| 509 | 544 | sendErrCh := make(chan error, 1) | |
| 510 | 545 | ||
| @@ -526,7 +561,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 526 | 561 | clip = clipboard | |
| 527 | 562 | } | |
| 528 | 563 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | |
| 529 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log) | |
| 564 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, themeState, requestColors, clip, log) | |
| 530 | 565 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | |
| 531 | 566 | ||
| 532 | 567 | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| @@ -726,12 +761,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 726 | 761 | // exits. That is harmless here — Attach has already returned and restored | |
| 727 | 762 | // the terminal — and matches the standard pattern for stdin pumps; a | |
| 728 | 763 | // closable stdin duplicate could lift it later if ever needed. | |
| 729 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) { | |
| 764 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, themeState *terminalThemeState, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) { | |
| 730 | 765 | defer log.Debug("stdin pump exited") | |
| 731 | 766 | buf := make([]byte, stdinBufSize) | |
| 732 | 767 | var scanner theme.Scanner | |
| 733 | 768 | var inputSeq uint64 | |
| 734 | - | current := ports.Theme{TrueColor: trueColor} | |
| 735 | 769 | var sendOK atomic.Bool | |
| 736 | 770 | sendOK.Store(true) | |
| 737 | 771 | send := func(frame ports.Frame) { | |
| @@ -776,28 +810,30 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 776 | 810 | for { | |
| 777 | 811 | n, rerr := in.Read(buf) | |
| 778 | 812 | if n > 0 { | |
| 779 | - | sendTheme := func() { | |
| 813 | + | sendTheme := func(current ports.Theme) { | |
| 780 | 814 | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | |
| 781 | 815 | } | |
| 782 | 816 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | |
| 783 | - | switch kind { | |
| 784 | - | case 10: | |
| 785 | - | current.HasForeground = true | |
| 786 | - | current.Foreground = rgb | |
| 787 | - | case 11: | |
| 788 | - | current.HasBackground = true | |
| 789 | - | current.Background = rgb | |
| 790 | - | default: | |
| 791 | - | return | |
| 792 | - | } | |
| 793 | - | sendTheme() | |
| 817 | + | current := themeState.update(func(current *ports.Theme) { | |
| 818 | + | switch kind { | |
| 819 | + | case 10: | |
| 820 | + | current.HasForeground = true | |
| 821 | + | current.Foreground = rgb | |
| 822 | + | case 11: | |
| 823 | + | current.HasBackground = true | |
| 824 | + | current.Background = rgb | |
| 825 | + | } | |
| 826 | + | }) | |
| 827 | + | sendTheme(current) | |
| 794 | 828 | }, func(light bool) { | |
| 795 | - | current.SchemeKnown = true | |
| 796 | - | current.Light = light | |
| 829 | + | current := themeState.update(func(current *ports.Theme) { | |
| 830 | + | current.SchemeKnown = true | |
| 831 | + | current.Light = light | |
| 832 | + | }) | |
| 797 | 833 | if requestColors != nil { | |
| 798 | 834 | requestColors() | |
| 799 | 835 | } | |
| 800 | - | sendTheme() | |
| 836 | + | sendTheme(current) | |
| 801 | 837 | }, sink) | |
| 802 | 838 | if !sendOK.Load() { | |
| 803 | 839 | return | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "log/slog" | 16 | "log/slog" | ||
| 17 | "os" | 17 | "os" | ||
| 18 | "strings" | 18 | "strings" | ||
| 19 | + | "sync" | |||
| 19 | "sync/atomic" | 20 | "sync/atomic" | ||
| 20 | "time" | 21 | "time" | ||
| 21 | 22 | ||||
| @@ -49,6 +50,34 @@ type DetachedError struct { | |||||
| 49 | 50 | ||||
| 50 | func (e *DetachedError) Error() string { return "vev: " + e.Text } | 51 | func (e *DetachedError) Error() string { return "vev: " + e.Text } | ||
| 51 | 52 | ||||
| 53 | + | // terminalThemeState retains the latest terminal-reported colors across | |||
| 54 | + | // transport attempts so a replacement attachment can restore the daemon theme | |||
| 55 | + | // without relying on another OSC response. | |||
| 56 | + | type terminalThemeState struct { | |||
| 57 | + | mu sync.Mutex | |||
| 58 | + | theme ports.Theme | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (s *terminalThemeState) setTrueColor(enabled bool) { | |||
| 62 | + | s.mu.Lock() | |||
| 63 | + | s.theme.TrueColor = enabled | |||
| 64 | + | s.mu.Unlock() | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme { | |||
| 68 | + | s.mu.Lock() | |||
| 69 | + | defer s.mu.Unlock() | |||
| 70 | + | update(&s.theme) | |||
| 71 | + | return s.theme | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (s *terminalThemeState) reportedTheme() (ports.Theme, bool) { | |||
| 75 | + | s.mu.Lock() | |||
| 76 | + | defer s.mu.Unlock() | |||
| 77 | + | theme := s.theme | |||
| 78 | + | return theme, theme.HasForeground || theme.HasBackground || theme.SchemeKnown | |||
| 79 | + | } | |||
| 80 | + | ||||
| 52 | // milestones tracks lifecycle progress so a failure can report which stages | 81 | // milestones tracks lifecycle progress so a failure can report which stages | ||
| 53 | // were never reached — the key diagnostic when an attach dies early. | 82 | // were never reached — the key diagnostic when an attach dies early. | ||
| 54 | type milestones struct { | 83 | type milestones struct { | ||
| @@ -187,6 +216,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||||
| 187 | backoff := defaultReconnectBackoff.initial | 216 | backoff := defaultReconnectBackoff.initial | ||
| 188 | showingStatus := false | 217 | showingStatus := false | ||
| 189 | var reconnectToastRect domain.Rect | 218 | var reconnectToastRect domain.Rect | ||
| 219 | + | themeState := &terminalThemeState{} | |||
| 190 | statusStage := reconnectStageOfflineRetrying | 220 | statusStage := reconnectStageOfflineRetrying | ||
| 191 | redrawRemoteStatus := func(size domain.Size) { | 221 | redrawRemoteStatus := func(size domain.Size) { | ||
| 192 | if !rawEntered || !remote { | 222 | if !rawEntered || !remote { | ||
| @@ -270,7 +300,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||||
| 270 | if reporter, ok := transport.(ports.LinkStateReporter); ok { | 300 | if reporter, ok := transport.(ports.LinkStateReporter); ok { | ||
| 271 | linkEvents = reporter.LinkEvents() | 301 | linkEvents = reporter.LinkEvents() | ||
| 272 | } | 302 | } | ||
| 273 | - | result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer) | 303 | + | result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, themeState, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer) |
| 274 | if result.welcomed { | 304 | if result.welcomed { | ||
| 275 | backoff = defaultReconnectBackoff.initial | 305 | backoff = defaultReconnectBackoff.initial | ||
| 276 | } | 306 | } | ||
| @@ -422,7 +452,7 @@ func requestedOutputWindow(transport ports.Transport) uint8 { | |||||
| 422 | return 8 | 452 | return 8 | ||
| 423 | } | 453 | } | ||
| 424 | 454 | ||||
| 425 | - | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult { | 455 | + | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, themeState *terminalThemeState, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult { |
| 426 | var observer ports.RuntimeObserver | 456 | var observer ports.RuntimeObserver | ||
| 427 | if len(observers) != 0 { | 457 | if len(observers) != 0 { | ||
| 428 | observer = observers[0] | 458 | observer = observers[0] | ||
| @@ -439,6 +469,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 439 | termEnv := os.Getenv("TERM") | 469 | termEnv := os.Getenv("TERM") | ||
| 440 | colorTerm := os.Getenv("COLORTERM") | 470 | colorTerm := os.Getenv("COLORTERM") | ||
| 441 | trueColor := DetectTrueColor(termEnv, colorTerm) | 471 | trueColor := DetectTrueColor(termEnv, colorTerm) | ||
| 472 | + | themeState.setTrueColor(trueColor) | |||
| 442 | hello := ports.Hello{ | 473 | hello := ports.Hello{ | ||
| 443 | Version: ports.ProtocolVersion, | 474 | Version: ports.ProtocolVersion, | ||
| 444 | Intent: intent, | 475 | Intent: intent, | ||
| @@ -497,6 +528,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 497 | return welcomedResult(err) | 528 | return welcomedResult(err) | ||
| 498 | } | 529 | } | ||
| 499 | clearStatus() | 530 | clearStatus() | ||
| 531 | + | reportedTheme, restoreTheme := themeState.reportedTheme() | |||
| 500 | 532 | ||||
| 501 | // 4. Derive a cancellable context so the pumps always unwind when the | 533 | // 4. Derive a cancellable context so the pumps always unwind when the | ||
| 502 | // loop returns. cancel runs first (LIFO): it signals the pumps; the | 534 | // loop returns. cancel runs first (LIFO): it signals the pumps; the | ||
| @@ -505,6 +537,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 505 | defer cancel() | 537 | defer cancel() | ||
| 506 | 538 | ||||
| 507 | sendCh := make(chan ports.Frame, sendQueueDepth) | 539 | sendCh := make(chan ports.Frame, sendQueueDepth) | ||
| 540 | + | if restoreTheme { | |||
| 541 | + | sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(reportedTheme)} | |||
| 542 | + | } | |||
| 508 | ackQueue := newCumulativeAckQueue() | 543 | ackQueue := newCumulativeAckQueue() | ||
| 509 | sendErrCh := make(chan error, 1) | 544 | sendErrCh := make(chan error, 1) | ||
| 510 | 545 | ||||
| @@ -526,7 +561,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 526 | clip = clipboard | 561 | clip = clipboard | ||
| 527 | } | 562 | } | ||
| 528 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | 563 | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | ||
| 529 | - | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log) | 564 | + | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, themeState, requestColors, clip, log) |
| 530 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | 565 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | ||
| 531 | 566 | ||||
| 532 | // 5. Output/main loop: the only goroutine that touches the terminal. | 567 | // 5. Output/main loop: the only goroutine that touches the terminal. | ||
| @@ -726,12 +761,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 726 | // exits. That is harmless here — Attach has already returned and restored | 761 | // exits. That is harmless here — Attach has already returned and restored | ||
| 727 | // the terminal — and matches the standard pattern for stdin pumps; a | 762 | // the terminal — and matches the standard pattern for stdin pumps; a | ||
| 728 | // closable stdin duplicate could lift it later if ever needed. | 763 | // closable stdin duplicate could lift it later if ever needed. | ||
| 729 | - | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, trueColor bool, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) { | 764 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, themeState *terminalThemeState, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) { |
| 730 | defer log.Debug("stdin pump exited") | 765 | defer log.Debug("stdin pump exited") | ||
| 731 | buf := make([]byte, stdinBufSize) | 766 | buf := make([]byte, stdinBufSize) | ||
| 732 | var scanner theme.Scanner | 767 | var scanner theme.Scanner | ||
| 733 | var inputSeq uint64 | 768 | var inputSeq uint64 | ||
| 734 | - | current := ports.Theme{TrueColor: trueColor} | |||
| 735 | var sendOK atomic.Bool | 769 | var sendOK atomic.Bool | ||
| 736 | sendOK.Store(true) | 770 | sendOK.Store(true) | ||
| 737 | send := func(frame ports.Frame) { | 771 | send := func(frame ports.Frame) { | ||
| @@ -776,28 +810,30 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 776 | for { | 810 | for { | ||
| 777 | n, rerr := in.Read(buf) | 811 | n, rerr := in.Read(buf) | ||
| 778 | if n > 0 { | 812 | if n > 0 { | ||
| 779 | - | sendTheme := func() { | 813 | + | sendTheme := func(current ports.Theme) { |
| 780 | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | 814 | send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)}) | ||
| 781 | } | 815 | } | ||
| 782 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | 816 | scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) { | ||
| 783 | - | switch kind { | 817 | + | current := themeState.update(func(current *ports.Theme) { |
| 784 | - | case 10: | 818 | + | switch kind { |
| 785 | - | current.HasForeground = true | 819 | + | case 10: |
| 786 | - | current.Foreground = rgb | 820 | + | current.HasForeground = true |
| 787 | - | case 11: | 821 | + | current.Foreground = rgb |
| 788 | - | current.HasBackground = true | 822 | + | case 11: |
| 789 | - | current.Background = rgb | 823 | + | current.HasBackground = true |
| 790 | - | default: | 824 | + | current.Background = rgb |
| 791 | - | return | 825 | + | } |
| 792 | - | } | 826 | + | }) |
| 793 | - | sendTheme() | 827 | + | sendTheme(current) |
| 794 | }, func(light bool) { | 828 | }, func(light bool) { | ||
| 795 | - | current.SchemeKnown = true | 829 | + | current := themeState.update(func(current *ports.Theme) { |
| 796 | - | current.Light = light | 830 | + | current.SchemeKnown = true |
| 831 | + | current.Light = light | |||
| 832 | + | }) | |||
| 797 | if requestColors != nil { | 833 | if requestColors != nil { | ||
| 798 | requestColors() | 834 | requestColors() | ||
| 799 | } | 835 | } | ||
| 800 | - | sendTheme() | 836 | + | sendTheme(current) |
| 801 | }, sink) | 837 | }, sink) | ||
| 802 | if !sendOK.Load() { | 838 | if !sendOK.Load() { | ||
| 803 | return | 839 | return | ||
M internal/usecase/client/reconnect_toast_test.go +160 -2
| @@ -202,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |||
| 202 | 202 | resultCh := make(chan attachResult, 1) | |
| 203 | 203 | ms := milestones{} | |
| 204 | 204 | go func() { | |
| 205 | - | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | |
| 205 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error { | |
| 206 | 206 | _, err := term.term.EnterRaw() | |
| 207 | 207 | return err | |
| 208 | 208 | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | |
| @@ -241,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||
| 241 | 241 | resultCh := make(chan attachResult, 1) | |
| 242 | 242 | ms := milestones{} | |
| 243 | 243 | go func() { | |
| 244 | - | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | |
| 244 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error { | |
| 245 | 245 | _, err := term.term.EnterRaw() | |
| 246 | 246 | return err | |
| 247 | 247 | }, func() {}, func(stage reconnectStage) { | |
| @@ -466,6 +466,164 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||
| 466 | 466 | require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width)) | |
| 467 | 467 | } | |
| 468 | 468 | ||
| 469 | + | type reconnectThemeTransport struct { | |
| 470 | + | resumeToken uint64 | |
| 471 | + | disconnectAfterTheme bool | |
| 472 | + | recvCount int | |
| 473 | + | fullTheme chan ports.Theme | |
| 474 | + | observedTheme ports.Theme | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | func newReconnectThemeTransport(token uint64, disconnectAfterTheme bool) *reconnectThemeTransport { | |
| 478 | + | return &reconnectThemeTransport{ | |
| 479 | + | resumeToken: token, | |
| 480 | + | disconnectAfterTheme: disconnectAfterTheme, | |
| 481 | + | fullTheme: make(chan ports.Theme, 1), | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | func (t *reconnectThemeTransport) Send(f ports.Frame) error { | |
| 486 | + | if f.Type != ports.MsgTheme { | |
| 487 | + | return nil | |
| 488 | + | } | |
| 489 | + | theme, err := ports.UnmarshalTheme(f.Payload) | |
| 490 | + | if err != nil { | |
| 491 | + | return err | |
| 492 | + | } | |
| 493 | + | if theme.HasForeground && theme.HasBackground { | |
| 494 | + | select { | |
| 495 | + | case t.fullTheme <- theme: | |
| 496 | + | default: | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | return nil | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | func (t *reconnectThemeTransport) Recv() (ports.Frame, error) { | |
| 503 | + | t.recvCount++ | |
| 504 | + | if t.recvCount == 1 { | |
| 505 | + | return reconnectToastWelcome(t.resumeToken), nil | |
| 506 | + | } | |
| 507 | + | t.observedTheme = <-t.fullTheme | |
| 508 | + | if t.disconnectAfterTheme { | |
| 509 | + | return ports.Frame{}, io.EOF | |
| 510 | + | } | |
| 511 | + | return reconnectToastDetach(ports.ReasonDetach), nil | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func (t *reconnectThemeTransport) Close() error { return nil } | |
| 515 | + | ||
| 516 | + | type blockingThemeRestoreTransport struct { | |
| 517 | + | started chan struct{} | |
| 518 | + | closed chan struct{} | |
| 519 | + | recvs int | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | func newBlockingThemeRestoreTransport() *blockingThemeRestoreTransport { | |
| 523 | + | return &blockingThemeRestoreTransport{started: make(chan struct{}), closed: make(chan struct{})} | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | func (t *blockingThemeRestoreTransport) Send(f ports.Frame) error { | |
| 527 | + | if f.Type != ports.MsgTheme { | |
| 528 | + | return nil | |
| 529 | + | } | |
| 530 | + | close(t.started) | |
| 531 | + | <-t.closed | |
| 532 | + | return io.ErrClosedPipe | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | func (t *blockingThemeRestoreTransport) Recv() (ports.Frame, error) { | |
| 536 | + | t.recvs++ | |
| 537 | + | if t.recvs == 1 { | |
| 538 | + | return reconnectToastWelcome(44), nil | |
| 539 | + | } | |
| 540 | + | <-t.closed | |
| 541 | + | return ports.Frame{}, io.EOF | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | func (t *blockingThemeRestoreTransport) Close() error { | |
| 545 | + | select { | |
| 546 | + | case <-t.closed: | |
| 547 | + | default: | |
| 548 | + | close(t.closed) | |
| 549 | + | } | |
| 550 | + | return nil | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) { | |
| 554 | + | term := newReconnectToastTerminalHarness(t) | |
| 555 | + | defer term.closeInput() | |
| 556 | + | themeState := &terminalThemeState{} | |
| 557 | + | themeState.setTrueColor(true) | |
| 558 | + | themeState.update(func(theme *ports.Theme) { | |
| 559 | + | theme.HasForeground = true | |
| 560 | + | theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3} | |
| 561 | + | theme.HasBackground = true | |
| 562 | + | theme.Background = renderer.RGB{R: 4, G: 5, B: 6} | |
| 563 | + | }) | |
| 564 | + | transport := newBlockingThemeRestoreTransport() | |
| 565 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 566 | + | resultCh := make(chan attachResult, 1) | |
| 567 | + | ms := milestones{} | |
| 568 | + | go func() { | |
| 569 | + | resultCh <- attachOnce(ctx, transport, term.term, newReconnectHandshakeClock(t), ports.IntentResume, "main", 44, [16]byte{1}, &ms, themeState, func() error { | |
| 570 | + | _, err := term.term.EnterRaw() | |
| 571 | + | return err | |
| 572 | + | }, func() {}, func(reconnectStage) {}, nil, true, nil, slog.New(slog.DiscardHandler)) | |
| 573 | + | }() | |
| 574 | + | ||
| 575 | + | select { | |
| 576 | + | case <-transport.started: | |
| 577 | + | case <-time.After(time.Second): | |
| 578 | + | cancel() | |
| 579 | + | _ = transport.Close() | |
| 580 | + | t.Fatal("cached theme restoration did not start") | |
| 581 | + | } | |
| 582 | + | cancel() | |
| 583 | + | select { | |
| 584 | + | case result := <-resultCh: | |
| 585 | + | require.True(t, result.welcomed) | |
| 586 | + | require.NoError(t, result.err) | |
| 587 | + | _ = transport.Close() | |
| 588 | + | case <-time.After(100 * time.Millisecond): | |
| 589 | + | _ = transport.Close() | |
| 590 | + | <-resultCh | |
| 591 | + | t.Fatal("cached theme send blocked attach cancellation") | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) { | |
| 596 | + | t.Setenv("TERM", "xterm-256color") | |
| 597 | + | t.Setenv("COLORTERM", "truecolor") | |
| 598 | + | oldSleep := reconnectSleep | |
| 599 | + | oldSleepWithResize := reconnectSleepWithResize | |
| 600 | + | reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true } | |
| 601 | + | reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool { | |
| 602 | + | return true | |
| 603 | + | } | |
| 604 | + | defer func() { | |
| 605 | + | reconnectSleep = oldSleep | |
| 606 | + | reconnectSleepWithResize = oldSleepWithResize | |
| 607 | + | }() | |
| 608 | + | ||
| 609 | + | term := newReconnectToastTerminalHarness(t) | |
| 610 | + | defer term.closeInput() | |
| 611 | + | go func() { | |
| 612 | + | _, _ = term.inWriter.Write([]byte("\x1b]10;#010203\x07\x1b]11;#040506\x07")) | |
| 613 | + | }() | |
| 614 | + | tr1 := newReconnectThemeTransport(44, true) | |
| 615 | + | tr2 := newReconnectThemeTransport(55, false) | |
| 616 | + | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | |
| 617 | + | ||
| 618 | + | err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |
| 619 | + | require.NoError(t, err) | |
| 620 | + | require.True(t, tr2.observedTheme.HasForeground) | |
| 621 | + | require.True(t, tr2.observedTheme.HasBackground) | |
| 622 | + | require.True(t, tr2.observedTheme.TrueColor) | |
| 623 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, tr2.observedTheme.Foreground) | |
| 624 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, tr2.observedTheme.Background) | |
| 625 | + | } | |
| 626 | + | ||
| 469 | 627 | func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |
| 470 | 628 | linkDead := errors.New("remote link dead") | |
| 471 | 629 | oldSleep := reconnectSleep | |
| @@ -202,7 +202,7 @@ func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) { | |||||
| 202 | resultCh := make(chan attachResult, 1) | 202 | resultCh := make(chan attachResult, 1) | ||
| 203 | ms := milestones{} | 203 | ms := milestones{} | ||
| 204 | go func() { | 204 | go func() { | ||
| 205 | - | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | 205 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error { |
| 206 | _, err := term.term.EnterRaw() | 206 | _, err := term.term.EnterRaw() | ||
| 207 | return err | 207 | return err | ||
| 208 | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | 208 | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | ||
| @@ -241,7 +241,7 @@ func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||||
| 241 | resultCh := make(chan attachResult, 1) | 241 | resultCh := make(chan attachResult, 1) | ||
| 242 | ms := milestones{} | 242 | ms := milestones{} | ||
| 243 | go func() { | 243 | go func() { | ||
| 244 | - | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | 244 | + | resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error { |
| 245 | _, err := term.term.EnterRaw() | 245 | _, err := term.term.EnterRaw() | ||
| 246 | return err | 246 | return err | ||
| 247 | }, func() {}, func(stage reconnectStage) { | 247 | }, func() {}, func(stage reconnectStage) { | ||
| @@ -466,6 +466,164 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||||
| 466 | require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width)) | 466 | require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width)) | ||
| 467 | } | 467 | } | ||
| 468 | 468 | ||||
| 469 | + | type reconnectThemeTransport struct { | |||
| 470 | + | resumeToken uint64 | |||
| 471 | + | disconnectAfterTheme bool | |||
| 472 | + | recvCount int | |||
| 473 | + | fullTheme chan ports.Theme | |||
| 474 | + | observedTheme ports.Theme | |||
| 475 | + | } | |||
| 476 | + | ||||
| 477 | + | func newReconnectThemeTransport(token uint64, disconnectAfterTheme bool) *reconnectThemeTransport { | |||
| 478 | + | return &reconnectThemeTransport{ | |||
| 479 | + | resumeToken: token, | |||
| 480 | + | disconnectAfterTheme: disconnectAfterTheme, | |||
| 481 | + | fullTheme: make(chan ports.Theme, 1), | |||
| 482 | + | } | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | func (t *reconnectThemeTransport) Send(f ports.Frame) error { | |||
| 486 | + | if f.Type != ports.MsgTheme { | |||
| 487 | + | return nil | |||
| 488 | + | } | |||
| 489 | + | theme, err := ports.UnmarshalTheme(f.Payload) | |||
| 490 | + | if err != nil { | |||
| 491 | + | return err | |||
| 492 | + | } | |||
| 493 | + | if theme.HasForeground && theme.HasBackground { | |||
| 494 | + | select { | |||
| 495 | + | case t.fullTheme <- theme: | |||
| 496 | + | default: | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | return nil | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func (t *reconnectThemeTransport) Recv() (ports.Frame, error) { | |||
| 503 | + | t.recvCount++ | |||
| 504 | + | if t.recvCount == 1 { | |||
| 505 | + | return reconnectToastWelcome(t.resumeToken), nil | |||
| 506 | + | } | |||
| 507 | + | t.observedTheme = <-t.fullTheme | |||
| 508 | + | if t.disconnectAfterTheme { | |||
| 509 | + | return ports.Frame{}, io.EOF | |||
| 510 | + | } | |||
| 511 | + | return reconnectToastDetach(ports.ReasonDetach), nil | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | func (t *reconnectThemeTransport) Close() error { return nil } | |||
| 515 | + | ||||
| 516 | + | type blockingThemeRestoreTransport struct { | |||
| 517 | + | started chan struct{} | |||
| 518 | + | closed chan struct{} | |||
| 519 | + | recvs int | |||
| 520 | + | } | |||
| 521 | + | ||||
| 522 | + | func newBlockingThemeRestoreTransport() *blockingThemeRestoreTransport { | |||
| 523 | + | return &blockingThemeRestoreTransport{started: make(chan struct{}), closed: make(chan struct{})} | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | func (t *blockingThemeRestoreTransport) Send(f ports.Frame) error { | |||
| 527 | + | if f.Type != ports.MsgTheme { | |||
| 528 | + | return nil | |||
| 529 | + | } | |||
| 530 | + | close(t.started) | |||
| 531 | + | <-t.closed | |||
| 532 | + | return io.ErrClosedPipe | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | func (t *blockingThemeRestoreTransport) Recv() (ports.Frame, error) { | |||
| 536 | + | t.recvs++ | |||
| 537 | + | if t.recvs == 1 { | |||
| 538 | + | return reconnectToastWelcome(44), nil | |||
| 539 | + | } | |||
| 540 | + | <-t.closed | |||
| 541 | + | return ports.Frame{}, io.EOF | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | func (t *blockingThemeRestoreTransport) Close() error { | |||
| 545 | + | select { | |||
| 546 | + | case <-t.closed: | |||
| 547 | + | default: | |||
| 548 | + | close(t.closed) | |||
| 549 | + | } | |||
| 550 | + | return nil | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) { | |||
| 554 | + | term := newReconnectToastTerminalHarness(t) | |||
| 555 | + | defer term.closeInput() | |||
| 556 | + | themeState := &terminalThemeState{} | |||
| 557 | + | themeState.setTrueColor(true) | |||
| 558 | + | themeState.update(func(theme *ports.Theme) { | |||
| 559 | + | theme.HasForeground = true | |||
| 560 | + | theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 561 | + | theme.HasBackground = true | |||
| 562 | + | theme.Background = renderer.RGB{R: 4, G: 5, B: 6} | |||
| 563 | + | }) | |||
| 564 | + | transport := newBlockingThemeRestoreTransport() | |||
| 565 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 566 | + | resultCh := make(chan attachResult, 1) | |||
| 567 | + | ms := milestones{} | |||
| 568 | + | go func() { | |||
| 569 | + | resultCh <- attachOnce(ctx, transport, term.term, newReconnectHandshakeClock(t), ports.IntentResume, "main", 44, [16]byte{1}, &ms, themeState, func() error { | |||
| 570 | + | _, err := term.term.EnterRaw() | |||
| 571 | + | return err | |||
| 572 | + | }, func() {}, func(reconnectStage) {}, nil, true, nil, slog.New(slog.DiscardHandler)) | |||
| 573 | + | }() | |||
| 574 | + | ||||
| 575 | + | select { | |||
| 576 | + | case <-transport.started: | |||
| 577 | + | case <-time.After(time.Second): | |||
| 578 | + | cancel() | |||
| 579 | + | _ = transport.Close() | |||
| 580 | + | t.Fatal("cached theme restoration did not start") | |||
| 581 | + | } | |||
| 582 | + | cancel() | |||
| 583 | + | select { | |||
| 584 | + | case result := <-resultCh: | |||
| 585 | + | require.True(t, result.welcomed) | |||
| 586 | + | require.NoError(t, result.err) | |||
| 587 | + | _ = transport.Close() | |||
| 588 | + | case <-time.After(100 * time.Millisecond): | |||
| 589 | + | _ = transport.Close() | |||
| 590 | + | <-resultCh | |||
| 591 | + | t.Fatal("cached theme send blocked attach cancellation") | |||
| 592 | + | } | |||
| 593 | + | } | |||
| 594 | + | ||||
| 595 | + | func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) { | |||
| 596 | + | t.Setenv("TERM", "xterm-256color") | |||
| 597 | + | t.Setenv("COLORTERM", "truecolor") | |||
| 598 | + | oldSleep := reconnectSleep | |||
| 599 | + | oldSleepWithResize := reconnectSleepWithResize | |||
| 600 | + | reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true } | |||
| 601 | + | reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool { | |||
| 602 | + | return true | |||
| 603 | + | } | |||
| 604 | + | defer func() { | |||
| 605 | + | reconnectSleep = oldSleep | |||
| 606 | + | reconnectSleepWithResize = oldSleepWithResize | |||
| 607 | + | }() | |||
| 608 | + | ||||
| 609 | + | term := newReconnectToastTerminalHarness(t) | |||
| 610 | + | defer term.closeInput() | |||
| 611 | + | go func() { | |||
| 612 | + | _, _ = term.inWriter.Write([]byte("\x1b]10;#010203\x07\x1b]11;#040506\x07")) | |||
| 613 | + | }() | |||
| 614 | + | tr1 := newReconnectThemeTransport(44, true) | |||
| 615 | + | tr2 := newReconnectThemeTransport(55, false) | |||
| 616 | + | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}} | |||
| 617 | + | ||||
| 618 | + | err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |||
| 619 | + | require.NoError(t, err) | |||
| 620 | + | require.True(t, tr2.observedTheme.HasForeground) | |||
| 621 | + | require.True(t, tr2.observedTheme.HasBackground) | |||
| 622 | + | require.True(t, tr2.observedTheme.TrueColor) | |||
| 623 | + | require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, tr2.observedTheme.Foreground) | |||
| 624 | + | require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, tr2.observedTheme.Background) | |||
| 625 | + | } | |||
| 626 | + | ||||
| 469 | func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | 627 | func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | ||
| 470 | linkDead := errors.New("remote link dead") | 628 | linkDead := errors.New("remote link dead") | ||
| 471 | oldSleep := reconnectSleep | 629 | oldSleep := reconnectSleep | ||