detached 6377eac

feat(app): wire daemon core into the --daemon path

commit
6377eac1d7f60393dfe6d467c6acec8ae8e9e1bd
author
brice · · 2026-07-02 11:59 UTC
parent
7e2f16e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
1111 "io"
1212 "log/slog"
1313 "os"
14+ "os/signal"
15+ "syscall"
1416 "text/tabwriter"
1517
18+ "github.com/bnema/vev/internal/adapters/clock"
1619 "github.com/bnema/vev/internal/adapters/ipc"
20+ "github.com/bnema/vev/internal/adapters/pty"
1721 "github.com/bnema/vev/internal/adapters/term"
1822 "github.com/bnema/vev/internal/ports"
1923 "github.com/bnema/vev/internal/usecase/client"
@@ -135,8 +139,9 @@ func dispatch(ctx context.Context, cmd command) error {
135139 }
136140
137141 // 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).
140145 func runDaemon() error {
141146 logFile, err := setupLogging()
142147 if err != nil {
@@ -150,10 +155,17 @@ func runDaemon() error {
150155 }
151156 defer ln.Close()
152157
158+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
159+ defer stop()
160+
153161 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
157169 }
158170
159171 // 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; here142+// 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 err164+ 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 client171 // runAttach dials (auto-spawning the daemon if needed) and runs the client

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close