feat(app): wire daemon core into the --daemon path
- commit
- 6377eac1d7f60393dfe6d467c6acec8ae8e9e1bd
- author
- brice · · 2026-07-02 11:59 UTC
- parent
- 7e2f16e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +204 -5
A internal/app/daemon_integration_test.go +187 -0
| @@ -0,0 +1,187 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | "log/slog" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 16 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 17 | + | "github.com/bnema/vev/internal/adapters/pty" | |
| 18 | + | "github.com/bnema/vev/internal/domain" | |
| 19 | + | "github.com/bnema/vev/internal/ports" | |
| 20 | + | "github.com/bnema/vev/internal/usecase/daemon" | |
| 21 | + | "github.com/bnema/vev/pkg/vt" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // These integration tests drive the real daemon over a real unix socket with a | |
| 25 | + | // real PTY and the real VT/renderer pipeline. They live in internal/app (which | |
| 26 | + | // is allowed to import adapters) because the layering guard forbids | |
| 27 | + | // internal/usecase from importing internal/adapters, even in tests. | |
| 28 | + | ||
| 29 | + | func discardLog() *slog.Logger { | |
| 30 | + | return slog.New(slog.NewTextHandler(io.Discard, nil)) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // startDaemon binds a real listener in a temp dir and serves in the background. | |
| 34 | + | // The returned served channel receives Serve's result exactly once. | |
| 35 | + | func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) { | |
| 36 | + | t.Helper() | |
| 37 | + | dir = t.TempDir() | |
| 38 | + | ln, err := ipc.Listen(dir) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | ||
| 41 | + | d := daemon.New(pty.NewFactory(), clock.New(), discardLog(), opts...) | |
| 42 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 43 | + | ch := make(chan error, 1) | |
| 44 | + | go func() { ch <- d.Serve(ctx, ln) }() | |
| 45 | + | t.Cleanup(cancel) | |
| 46 | + | return dir, ch | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // pump wires a background Recv pump to a transport so tests can await frames | |
| 50 | + | // with a timeout (ports.Transport has no read deadline). | |
| 51 | + | type pump struct{ ch chan ports.Frame } | |
| 52 | + | ||
| 53 | + | func recvPump(tr ports.Transport) *pump { | |
| 54 | + | p := &pump{ch: make(chan ports.Frame, 128)} | |
| 55 | + | go func() { | |
| 56 | + | for { | |
| 57 | + | f, err := tr.Recv() | |
| 58 | + | if err != nil { | |
| 59 | + | close(p.ch) | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | p.ch <- f | |
| 63 | + | } | |
| 64 | + | }() | |
| 65 | + | return p | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // attach dials, handshakes, and returns the transport plus its frame pump. | |
| 69 | + | func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) { | |
| 70 | + | t.Helper() | |
| 71 | + | tr, err := ipc.Dial(dir) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"} | |
| 74 | + | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})) | |
| 75 | + | p := recvPump(tr) | |
| 76 | + | select { | |
| 77 | + | case f, ok := <-p.ch: | |
| 78 | + | require.True(t, ok, "connection closed before welcome") | |
| 79 | + | require.Equal(t, ports.MsgWelcome, f.Type) | |
| 80 | + | case <-time.After(3 * time.Second): | |
| 81 | + | t.Fatal("timed out waiting for welcome") | |
| 82 | + | } | |
| 83 | + | return tr, p | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // awaitText decodes MsgOutput frames into a fresh VT screen and returns once | |
| 87 | + | // the reconstructed grid contains want. | |
| 88 | + | func awaitText(t *testing.T, p *pump, sz domain.Size, want string) { | |
| 89 | + | t.Helper() | |
| 90 | + | screen := vt.NewScreen(sz.Cols, sz.Rows) | |
| 91 | + | timeout := time.After(5 * time.Second) | |
| 92 | + | for { | |
| 93 | + | select { | |
| 94 | + | case f, ok := <-p.ch: | |
| 95 | + | if !ok { | |
| 96 | + | t.Fatalf("connection closed before %q appeared; screen=%q", want, screenText(screen)) | |
| 97 | + | } | |
| 98 | + | if f.Type == ports.MsgOutput { | |
| 99 | + | o, err := ports.UnmarshalOutput(f.Payload) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | screen.Write(o.Data) | |
| 102 | + | if strings.Contains(screenText(screen), want) { | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | case <-timeout: | |
| 107 | + | t.Fatalf("timed out waiting for %q; screen=%q", want, screenText(screen)) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func screenText(s *vt.Screen) string { | |
| 113 | + | var b strings.Builder | |
| 114 | + | for y := 0; y < s.Frame.Height; y++ { | |
| 115 | + | for x := 0; x < s.Frame.Width; x++ { | |
| 116 | + | r := s.Frame.At(x, y).Rune | |
| 117 | + | if r == 0 { | |
| 118 | + | r = ' ' | |
| 119 | + | } | |
| 120 | + | b.WriteRune(r) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | return b.String() | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestIntegration_AttachFirstOutput(t *testing.T) { | |
| 127 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 128 | + | dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"})) | |
| 129 | + | ||
| 130 | + | tr, p := attach(t, dir, ports.IntentEphemeral, "", sz) | |
| 131 | + | defer tr.Close() | |
| 132 | + | ||
| 133 | + | awaitText(t, p, sz, "HELLO") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestIntegration_InputRoundtrip(t *testing.T) { | |
| 137 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 138 | + | dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil)) | |
| 139 | + | ||
| 140 | + | tr, p := attach(t, dir, ports.IntentEphemeral, "", sz) | |
| 141 | + | defer tr.Close() | |
| 142 | + | ||
| 143 | + | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("PINGPONG\n")})})) | |
| 144 | + | awaitText(t, p, sz, "PINGPONG") | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestIntegration_EphemeralGoneOnDetach(t *testing.T) { | |
| 148 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 149 | + | dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"})) | |
| 150 | + | ||
| 151 | + | tr, p := attach(t, dir, ports.IntentEphemeral, "", sz) | |
| 152 | + | awaitText(t, p, sz, "READY") | |
| 153 | + | ||
| 154 | + | // Detach by dropping the connection: the ephemeral session must die, which | |
| 155 | + | // (being the last session) shuts the daemon down. | |
| 156 | + | require.NoError(t, tr.Close()) | |
| 157 | + | ||
| 158 | + | select { | |
| 159 | + | case err := <-served: | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | case <-time.After(5 * time.Second): | |
| 162 | + | t.Fatal("daemon did not shut down after the ephemeral session detached") | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestIntegration_NamedSurvivesReattach(t *testing.T) { | |
| 167 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 168 | + | dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"})) | |
| 169 | + | ||
| 170 | + | tr1, p1 := attach(t, dir, ports.IntentNew, "work", sz) | |
| 171 | + | awaitText(t, p1, sz, "MARKER") | |
| 172 | + | ||
| 173 | + | // Detach: a named session must survive. | |
| 174 | + | require.NoError(t, tr1.Close()) | |
| 175 | + | ||
| 176 | + | // Daemon must still be running (session alive). | |
| 177 | + | select { | |
| 178 | + | case err := <-served: | |
| 179 | + | t.Fatalf("daemon shut down while a named session was alive: %v", err) | |
| 180 | + | case <-time.After(300 * time.Millisecond): | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // Re-attach: the first paint must reproduce the retained screen state. | |
| 184 | + | tr2, p2 := attach(t, dir, ports.IntentAttach, "work", sz) | |
| 185 | + | defer tr2.Close() | |
| 186 | + | awaitText(t, p2, sz, "MARKER") | |
| 187 | + | } | |
| @@ -0,0 +1,187 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | "log/slog" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 16 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 17 | + | "github.com/bnema/vev/internal/adapters/pty" | |||
| 18 | + | "github.com/bnema/vev/internal/domain" | |||
| 19 | + | "github.com/bnema/vev/internal/ports" | |||
| 20 | + | "github.com/bnema/vev/internal/usecase/daemon" | |||
| 21 | + | "github.com/bnema/vev/pkg/vt" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // These integration tests drive the real daemon over a real unix socket with a | |||
| 25 | + | // real PTY and the real VT/renderer pipeline. They live in internal/app (which | |||
| 26 | + | // is allowed to import adapters) because the layering guard forbids | |||
| 27 | + | // internal/usecase from importing internal/adapters, even in tests. | |||
| 28 | + | ||||
| 29 | + | func discardLog() *slog.Logger { | |||
| 30 | + | return slog.New(slog.NewTextHandler(io.Discard, nil)) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // startDaemon binds a real listener in a temp dir and serves in the background. | |||
| 34 | + | // The returned served channel receives Serve's result exactly once. | |||
| 35 | + | func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) { | |||
| 36 | + | t.Helper() | |||
| 37 | + | dir = t.TempDir() | |||
| 38 | + | ln, err := ipc.Listen(dir) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | ||||
| 41 | + | d := daemon.New(pty.NewFactory(), clock.New(), discardLog(), opts...) | |||
| 42 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 43 | + | ch := make(chan error, 1) | |||
| 44 | + | go func() { ch <- d.Serve(ctx, ln) }() | |||
| 45 | + | t.Cleanup(cancel) | |||
| 46 | + | return dir, ch | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // pump wires a background Recv pump to a transport so tests can await frames | |||
| 50 | + | // with a timeout (ports.Transport has no read deadline). | |||
| 51 | + | type pump struct{ ch chan ports.Frame } | |||
| 52 | + | ||||
| 53 | + | func recvPump(tr ports.Transport) *pump { | |||
| 54 | + | p := &pump{ch: make(chan ports.Frame, 128)} | |||
| 55 | + | go func() { | |||
| 56 | + | for { | |||
| 57 | + | f, err := tr.Recv() | |||
| 58 | + | if err != nil { | |||
| 59 | + | close(p.ch) | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | p.ch <- f | |||
| 63 | + | } | |||
| 64 | + | }() | |||
| 65 | + | return p | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // attach dials, handshakes, and returns the transport plus its frame pump. | |||
| 69 | + | func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) { | |||
| 70 | + | t.Helper() | |||
| 71 | + | tr, err := ipc.Dial(dir) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"} | |||
| 74 | + | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})) | |||
| 75 | + | p := recvPump(tr) | |||
| 76 | + | select { | |||
| 77 | + | case f, ok := <-p.ch: | |||
| 78 | + | require.True(t, ok, "connection closed before welcome") | |||
| 79 | + | require.Equal(t, ports.MsgWelcome, f.Type) | |||
| 80 | + | case <-time.After(3 * time.Second): | |||
| 81 | + | t.Fatal("timed out waiting for welcome") | |||
| 82 | + | } | |||
| 83 | + | return tr, p | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // awaitText decodes MsgOutput frames into a fresh VT screen and returns once | |||
| 87 | + | // the reconstructed grid contains want. | |||
| 88 | + | func awaitText(t *testing.T, p *pump, sz domain.Size, want string) { | |||
| 89 | + | t.Helper() | |||
| 90 | + | screen := vt.NewScreen(sz.Cols, sz.Rows) | |||
| 91 | + | timeout := time.After(5 * time.Second) | |||
| 92 | + | for { | |||
| 93 | + | select { | |||
| 94 | + | case f, ok := <-p.ch: | |||
| 95 | + | if !ok { | |||
| 96 | + | t.Fatalf("connection closed before %q appeared; screen=%q", want, screenText(screen)) | |||
| 97 | + | } | |||
| 98 | + | if f.Type == ports.MsgOutput { | |||
| 99 | + | o, err := ports.UnmarshalOutput(f.Payload) | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | screen.Write(o.Data) | |||
| 102 | + | if strings.Contains(screenText(screen), want) { | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | case <-timeout: | |||
| 107 | + | t.Fatalf("timed out waiting for %q; screen=%q", want, screenText(screen)) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func screenText(s *vt.Screen) string { | |||
| 113 | + | var b strings.Builder | |||
| 114 | + | for y := 0; y < s.Frame.Height; y++ { | |||
| 115 | + | for x := 0; x < s.Frame.Width; x++ { | |||
| 116 | + | r := s.Frame.At(x, y).Rune | |||
| 117 | + | if r == 0 { | |||
| 118 | + | r = ' ' | |||
| 119 | + | } | |||
| 120 | + | b.WriteRune(r) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | return b.String() | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestIntegration_AttachFirstOutput(t *testing.T) { | |||
| 127 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 128 | + | dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"})) | |||
| 129 | + | ||||
| 130 | + | tr, p := attach(t, dir, ports.IntentEphemeral, "", sz) | |||
| 131 | + | defer tr.Close() | |||
| 132 | + | ||||
| 133 | + | awaitText(t, p, sz, "HELLO") | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestIntegration_InputRoundtrip(t *testing.T) { | |||
| 137 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 138 | + | dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil)) | |||
| 139 | + | ||||
| 140 | + | tr, p := attach(t, dir, ports.IntentEphemeral, "", sz) | |||
| 141 | + | defer tr.Close() | |||
| 142 | + | ||||
| 143 | + | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("PINGPONG\n")})})) | |||
| 144 | + | awaitText(t, p, sz, "PINGPONG") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestIntegration_EphemeralGoneOnDetach(t *testing.T) { | |||
| 148 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 149 | + | dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"})) | |||
| 150 | + | ||||
| 151 | + | tr, p := attach(t, dir, ports.IntentEphemeral, "", sz) | |||
| 152 | + | awaitText(t, p, sz, "READY") | |||
| 153 | + | ||||
| 154 | + | // Detach by dropping the connection: the ephemeral session must die, which | |||
| 155 | + | // (being the last session) shuts the daemon down. | |||
| 156 | + | require.NoError(t, tr.Close()) | |||
| 157 | + | ||||
| 158 | + | select { | |||
| 159 | + | case err := <-served: | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | case <-time.After(5 * time.Second): | |||
| 162 | + | t.Fatal("daemon did not shut down after the ephemeral session detached") | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestIntegration_NamedSurvivesReattach(t *testing.T) { | |||
| 167 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 168 | + | dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"})) | |||
| 169 | + | ||||
| 170 | + | tr1, p1 := attach(t, dir, ports.IntentNew, "work", sz) | |||
| 171 | + | awaitText(t, p1, sz, "MARKER") | |||
| 172 | + | ||||
| 173 | + | // Detach: a named session must survive. | |||
| 174 | + | require.NoError(t, tr1.Close()) | |||
| 175 | + | ||||
| 176 | + | // Daemon must still be running (session alive). | |||
| 177 | + | select { | |||
| 178 | + | case err := <-served: | |||
| 179 | + | t.Fatalf("daemon shut down while a named session was alive: %v", err) | |||
| 180 | + | case <-time.After(300 * time.Millisecond): | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // Re-attach: the first paint must reproduce the retained screen state. | |||
| 184 | + | tr2, p2 := attach(t, dir, ports.IntentAttach, "work", sz) | |||
| 185 | + | defer tr2.Close() | |||
| 186 | + | awaitText(t, p2, sz, "MARKER") | |||
| 187 | + | } | |||
M internal/app/run.go +17 -5
| @@ -11,9 +11,13 @@ import ( | |||
| 11 | 11 | "io" | |
| 12 | 12 | "log/slog" | |
| 13 | 13 | "os" | |
| 14 | + | "os/signal" | |
| 15 | + | "syscall" | |
| 14 | 16 | "text/tabwriter" | |
| 15 | 17 | ||
| 18 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 16 | 19 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 20 | + | "github.com/bnema/vev/internal/adapters/pty" | |
| 17 | 21 | "github.com/bnema/vev/internal/adapters/term" | |
| 18 | 22 | "github.com/bnema/vev/internal/ports" | |
| 19 | 23 | "github.com/bnema/vev/internal/usecase/client" | |
| @@ -135,8 +139,9 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 135 | 139 | } | |
| 136 | 140 | ||
| 137 | 141 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |
| 138 | - | // entered by an auto-spawned child). The daemon core lands in Task 11; here | |
| 139 | - | // we set up logging, bind the socket, and hand off to daemon.Serve. | |
| 142 | + | // entered by an auto-spawned child): it sets up logging, binds the socket, | |
| 143 | + | // constructs the daemon, and serves until the last session exits or a | |
| 144 | + | // termination signal arrives (graceful shutdown notifies attached clients). | |
| 140 | 145 | func runDaemon() error { | |
| 141 | 146 | logFile, err := setupLogging() | |
| 142 | 147 | if err != nil { | |
| @@ -150,10 +155,17 @@ func runDaemon() error { | |||
| 150 | 155 | } | |
| 151 | 156 | defer ln.Close() | |
| 152 | 157 | ||
| 158 | + | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) | |
| 159 | + | defer stop() | |
| 160 | + | ||
| 153 | 161 | slog.Info("daemon starting", "socket", ln.Addr()) | |
| 154 | - | err = daemon.Serve(ln) | |
| 155 | - | slog.Error("daemon exited", "err", err) | |
| 156 | - | return err | |
| 162 | + | d := daemon.New(pty.NewFactory(), clock.New(), slog.Default()) | |
| 163 | + | if err := d.Serve(ctx, ln); err != nil { | |
| 164 | + | slog.Error("daemon exited", "err", err) | |
| 165 | + | return err | |
| 166 | + | } | |
| 167 | + | slog.Info("daemon exited cleanly") | |
| 168 | + | return nil | |
| 157 | 169 | } | |
| 158 | 170 | ||
| 159 | 171 | // runAttach dials (auto-spawning the daemon if needed) and runs the client | |
| @@ -11,9 +11,13 @@ import ( | |||||
| 11 | "io" | 11 | "io" | ||
| 12 | "log/slog" | 12 | "log/slog" | ||
| 13 | "os" | 13 | "os" | ||
| 14 | + | "os/signal" | |||
| 15 | + | "syscall" | |||
| 14 | "text/tabwriter" | 16 | "text/tabwriter" | ||
| 15 | 17 | ||||
| 18 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 16 | "github.com/bnema/vev/internal/adapters/ipc" | 19 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 20 | + | "github.com/bnema/vev/internal/adapters/pty" | |||
| 17 | "github.com/bnema/vev/internal/adapters/term" | 21 | "github.com/bnema/vev/internal/adapters/term" | ||
| 18 | "github.com/bnema/vev/internal/ports" | 22 | "github.com/bnema/vev/internal/ports" | ||
| 19 | "github.com/bnema/vev/internal/usecase/client" | 23 | "github.com/bnema/vev/internal/usecase/client" | ||
| @@ -135,8 +139,9 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 135 | } | 139 | } | ||
| 136 | 140 | ||||
| 137 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | 141 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | ||
| 138 | - | // entered by an auto-spawned child). The daemon core lands in Task 11; here | 142 | + | // entered by an auto-spawned child): it sets up logging, binds the socket, |
| 139 | - | // we set up logging, bind the socket, and hand off to daemon.Serve. | 143 | + | // constructs the daemon, and serves until the last session exits or a |
| 144 | + | // termination signal arrives (graceful shutdown notifies attached clients). | |||
| 140 | func runDaemon() error { | 145 | func runDaemon() error { | ||
| 141 | logFile, err := setupLogging() | 146 | logFile, err := setupLogging() | ||
| 142 | if err != nil { | 147 | if err != nil { | ||
| @@ -150,10 +155,17 @@ func runDaemon() error { | |||||
| 150 | } | 155 | } | ||
| 151 | defer ln.Close() | 156 | defer ln.Close() | ||
| 152 | 157 | ||||
| 158 | + | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) | |||
| 159 | + | defer stop() | |||
| 160 | + | ||||
| 153 | slog.Info("daemon starting", "socket", ln.Addr()) | 161 | slog.Info("daemon starting", "socket", ln.Addr()) | ||
| 154 | - | err = daemon.Serve(ln) | 162 | + | d := daemon.New(pty.NewFactory(), clock.New(), slog.Default()) |
| 155 | - | slog.Error("daemon exited", "err", err) | 163 | + | if err := d.Serve(ctx, ln); err != nil { |
| 156 | - | return err | 164 | + | slog.Error("daemon exited", "err", err) |
| 165 | + | return err | |||
| 166 | + | } | |||
| 167 | + | slog.Info("daemon exited cleanly") | |||
| 168 | + | return nil | |||
| 157 | } | 169 | } | ||
| 158 | 170 | ||||
| 159 | // runAttach dials (auto-spawning the daemon if needed) and runs the client | 171 | // runAttach dials (auto-spawning the daemon if needed) and runs the client | ||