detached e48677f

fix(ipc): make daemon dialing cancellable

commit
e48677f6b878e2171d07494e9c7b3a94b51d3db5
author
brice · · 2026-07-13 03:42 UTC
parent
0a1ab39
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +143 -31
M internal/adapters/ipc/dial.go +6 -4
@@ -1,6 +1,7 @@
11 package ipc
22
33 import (
4+ "context"
45 "fmt"
56 "net"
67 "path/filepath"
@@ -8,13 +9,14 @@ import (
89 "github.com/bnema/vev/internal/ports"
910 )
1011
11-// Dial connects to the daemon's socket in dir (dir/daemon.sock) and returns
12-// a Transport speaking vev's framed protocol. A dial failure (no daemon
12+// DialContext connects to the daemon's socket in dir (dir/daemon.sock) and
13+// returns a Transport speaking vev's framed protocol. A dial failure (no daemon
1314 // listening, or a stale/absent socket) is reported as a wrapped error so
1415 // callers can decide whether to spawn a daemon and retry.
15-func Dial(dir string, opts ...Option) (ports.Transport, error) {
16+func DialContext(ctx context.Context, dir string, opts ...Option) (ports.Transport, error) {
1617 sockPath := filepath.Join(dir, socketFileName)
17- conn, err := net.Dial("unix", sockPath)
18+ var dialer net.Dialer
19+ conn, err := dialer.DialContext(ctx, "unix", sockPath)
1820 if err != nil {
1921 return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err)
2022 }
@@ -1,6 +1,7 @@
1 package ipc1 package ipc
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "net"6 "net"
6 "path/filepath"7 "path/filepath"
@@ -8,13 +9,14 @@ import (
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 )10 )
10 11
11-// Dial connects to the daemon's socket in dir (dir/daemon.sock) and returns12+// DialContext connects to the daemon's socket in dir (dir/daemon.sock) and
12-// a Transport speaking vev's framed protocol. A dial failure (no daemon13+// returns a Transport speaking vev's framed protocol. A dial failure (no daemon
13 // listening, or a stale/absent socket) is reported as a wrapped error so14 // listening, or a stale/absent socket) is reported as a wrapped error so
14 // callers can decide whether to spawn a daemon and retry.15 // callers can decide whether to spawn a daemon and retry.
15-func Dial(dir string, opts ...Option) (ports.Transport, error) {16+func DialContext(ctx context.Context, dir string, opts ...Option) (ports.Transport, error) {
16 sockPath := filepath.Join(dir, socketFileName)17 sockPath := filepath.Join(dir, socketFileName)
17- conn, err := net.Dial("unix", sockPath)18+ var dialer net.Dialer
19+ conn, err := dialer.DialContext(ctx, "unix", sockPath)
18 if err != nil {20 if err != nil {
19 return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err)21 return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err)
20 }22 }
A internal/adapters/ipc/dial_test.go +79 -0
@@ -0,0 +1,79 @@
1+package ipc
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+type dialTestObserver struct{}
14+
15+func (dialTestObserver) ObserveRuntime(ports.RuntimeMark) {}
16+
17+func TestDialContextCanceledOrExpired(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ ctx func() (context.Context, context.CancelFunc)
21+ want error
22+ }{
23+ {
24+ name: "canceled",
25+ ctx: func() (context.Context, context.CancelFunc) {
26+ ctx, cancel := context.WithCancel(context.Background())
27+ cancel()
28+ return ctx, func() {}
29+ },
30+ want: context.Canceled,
31+ },
32+ {
33+ name: "deadline exceeded",
34+ ctx: func() (context.Context, context.CancelFunc) {
35+ return context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
36+ },
37+ want: context.DeadlineExceeded,
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ ctx, cancel := tt.ctx()
44+ defer cancel()
45+
46+ transport, err := DialContext(ctx, t.TempDir())
47+ if transport != nil {
48+ t.Fatal("DialContext returned a transport for a completed context")
49+ }
50+ if !errors.Is(err, tt.want) {
51+ t.Fatalf("DialContext error = %v, want wrapping %v", err, tt.want)
52+ }
53+ })
54+ }
55+}
56+
57+func TestDialContextPreservesOptions(t *testing.T) {
58+ dir := filepath.Join(t.TempDir(), "vev")
59+ listener, err := Listen(dir)
60+ if err != nil {
61+ t.Fatalf("Listen: %v", err)
62+ }
63+ defer func() { _ = listener.Close() }()
64+
65+ observer := dialTestObserver{}
66+ transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))
67+ if err != nil {
68+ t.Fatalf("DialContext: %v", err)
69+ }
70+ defer func() { _ = transport.Close() }()
71+
72+ got, ok := transport.(*unixTransport)
73+ if !ok {
74+ t.Fatalf("DialContext transport = %T, want *unixTransport", transport)
75+ }
76+ if got.observer != observer {
77+ t.Fatal("DialContext did not apply runtime observer option")
78+ }
79+}
@@ -0,0 +1,79 @@
1+package ipc
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+type dialTestObserver struct{}
14+
15+func (dialTestObserver) ObserveRuntime(ports.RuntimeMark) {}
16+
17+func TestDialContextCanceledOrExpired(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ ctx func() (context.Context, context.CancelFunc)
21+ want error
22+ }{
23+ {
24+ name: "canceled",
25+ ctx: func() (context.Context, context.CancelFunc) {
26+ ctx, cancel := context.WithCancel(context.Background())
27+ cancel()
28+ return ctx, func() {}
29+ },
30+ want: context.Canceled,
31+ },
32+ {
33+ name: "deadline exceeded",
34+ ctx: func() (context.Context, context.CancelFunc) {
35+ return context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
36+ },
37+ want: context.DeadlineExceeded,
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ ctx, cancel := tt.ctx()
44+ defer cancel()
45+
46+ transport, err := DialContext(ctx, t.TempDir())
47+ if transport != nil {
48+ t.Fatal("DialContext returned a transport for a completed context")
49+ }
50+ if !errors.Is(err, tt.want) {
51+ t.Fatalf("DialContext error = %v, want wrapping %v", err, tt.want)
52+ }
53+ })
54+ }
55+}
56+
57+func TestDialContextPreservesOptions(t *testing.T) {
58+ dir := filepath.Join(t.TempDir(), "vev")
59+ listener, err := Listen(dir)
60+ if err != nil {
61+ t.Fatalf("Listen: %v", err)
62+ }
63+ defer func() { _ = listener.Close() }()
64+
65+ observer := dialTestObserver{}
66+ transport, err := DialContext(context.Background(), dir, WithRuntimeObserver(observer))
67+ if err != nil {
68+ t.Fatalf("DialContext: %v", err)
69+ }
70+ defer func() { _ = transport.Close() }()
71+
72+ got, ok := transport.(*unixTransport)
73+ if !ok {
74+ t.Fatalf("DialContext transport = %T, want *unixTransport", transport)
75+ }
76+ if got.observer != observer {
77+ t.Fatal("DialContext did not apply runtime observer option")
78+ }
79+}
M internal/app/daemon_integration_test.go +5 -5
@@ -74,7 +74,7 @@ func recvPump(tr ports.Transport) *pump {
7474 // attach dials, handshakes, and returns the transport plus its frame pump.
7575 func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {
7676 t.Helper()
77- tr, err := ipc.Dial(dir)
77+ tr, err := ipc.DialContext(context.Background(), dir)
7878 require.NoError(t, err)
7979 hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"}
8080 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}))
@@ -91,7 +91,7 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
9191
9292 func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
9393 t.Helper()
94- tr, err := ipc.Dial(dir)
94+ tr, err := ipc.DialContext(context.Background(), dir)
9595 require.NoError(t, err)
9696 defer func() { _ = tr.Close() }()
9797 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
@@ -104,7 +104,7 @@ func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
104104 }
105105
106106 func killAll(dir string) error {
107- tr, err := ipc.Dial(dir)
107+ tr, err := ipc.DialContext(context.Background(), dir)
108108 if err != nil {
109109 return err
110110 }
@@ -248,7 +248,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
248248 text = awaitScreenText(t, p, sz, " work ")
249249 require.NotContains(t, text, "work*")
250250
251- listTr, err := ipc.Dial(dir)
251+ listTr, err := ipc.DialContext(context.Background(), dir)
252252 require.NoError(t, err)
253253 defer func() { _ = listTr.Close() }()
254254 require.NoError(t, listTr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
@@ -367,7 +367,7 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
367367 tr2, _ := attach(t, dir, ports.IntentNew, "two", sz)
368368 require.NoError(t, tr2.Close())
369369
370- killTr, err := ipc.Dial(dir)
370+ killTr, err := ipc.DialContext(context.Background(), dir)
371371 require.NoError(t, err)
372372 defer func() { _ = killTr.Close() }()
373373 require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}))
@@ -74,7 +74,7 @@ func recvPump(tr ports.Transport) *pump {
74 // attach dials, handshakes, and returns the transport plus its frame pump.74 // attach dials, handshakes, and returns the transport plus its frame pump.
75 func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {75 func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {
76 t.Helper()76 t.Helper()
77- tr, err := ipc.Dial(dir)77+ tr, err := ipc.DialContext(context.Background(), dir)
78 require.NoError(t, err)78 require.NoError(t, err)
79 hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"}79 hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"}
80 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}))80 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}))
@@ -91,7 +91,7 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
91 91
92 func listRemoteSessions(t *testing.T, dir string) ports.Sessions {92 func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
93 t.Helper()93 t.Helper()
94- tr, err := ipc.Dial(dir)94+ tr, err := ipc.DialContext(context.Background(), dir)
95 require.NoError(t, err)95 require.NoError(t, err)
96 defer func() { _ = tr.Close() }()96 defer func() { _ = tr.Close() }()
97 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))97 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
@@ -104,7 +104,7 @@ func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
104 }104 }
105 105
106 func killAll(dir string) error {106 func killAll(dir string) error {
107- tr, err := ipc.Dial(dir)107+ tr, err := ipc.DialContext(context.Background(), dir)
108 if err != nil {108 if err != nil {
109 return err109 return err
110 }110 }
@@ -248,7 +248,7 @@ func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) {
248 text = awaitScreenText(t, p, sz, " work ")248 text = awaitScreenText(t, p, sz, " work ")
249 require.NotContains(t, text, "work*")249 require.NotContains(t, text, "work*")
250 250
251- listTr, err := ipc.Dial(dir)251+ listTr, err := ipc.DialContext(context.Background(), dir)
252 require.NoError(t, err)252 require.NoError(t, err)
253 defer func() { _ = listTr.Close() }()253 defer func() { _ = listTr.Close() }()
254 require.NoError(t, listTr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))254 require.NoError(t, listTr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
@@ -367,7 +367,7 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
367 tr2, _ := attach(t, dir, ports.IntentNew, "two", sz)367 tr2, _ := attach(t, dir, ports.IntentNew, "two", sz)
368 require.NoError(t, tr2.Close())368 require.NoError(t, tr2.Close())
369 369
370- killTr, err := ipc.Dial(dir)370+ killTr, err := ipc.DialContext(context.Background(), dir)
371 require.NoError(t, err)371 require.NoError(t, err)
372 defer func() { _ = killTr.Close() }()372 defer func() { _ = killTr.Close() }()
373 require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}))373 require.NoError(t, killTr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}))
M internal/app/run.go +11 -11
@@ -565,8 +565,8 @@ type localDaemonDialer struct {
565565 func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
566566 dial := realDial
567567 if d.observer != nil {
568- dial = func(dir string) (ports.Transport, error) {
569- return ipc.Dial(dir, ipc.WithRuntimeObserver(d.observer))
568+ dial = func(ctx context.Context, dir string) (ports.Transport, error) {
569+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))
570570 }
571571 }
572572 return ensureDaemon(ctx, d.dir, dial, realSpawn, defaultBackoff)
@@ -651,7 +651,7 @@ func requestDaemonStop(ctx context.Context) error {
651651
652652 done := make(chan error, 1)
653653 go func() {
654- transport, err := realDial(ipc.SocketDir())
654+ transport, err := realDial(ctx, ipc.SocketDir())
655655 if err != nil {
656656 done <- fmt.Errorf("vev: no daemon running")
657657 return
@@ -704,8 +704,8 @@ func runStdio(ctx context.Context) error {
704704 if observerCloser != nil {
705705 defer func() { _ = observerCloser.Close() }()
706706 }
707- transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(dir string) (ports.Transport, error) {
708- return ipc.Dial(dir, ipc.WithRuntimeObserver(observer))
707+ transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
708+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
709709 }, realSpawn, defaultBackoff)
710710 if err != nil {
711711 return err
@@ -823,8 +823,8 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
823823 if _, err := rand.Read(key); err != nil {
824824 return err
825825 }
826- daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(dir string) (ports.Transport, error) {
827- return ipc.Dial(dir, ipc.WithRuntimeObserver(observer))
826+ daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
827+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
828828 }, realSpawn, defaultBackoff)
829829 if err != nil {
830830 return err
@@ -975,8 +975,8 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
975975
976976 // runList prints the daemon's session listing. With no daemon running, it
977977 // falls back to the persisted stopped-session records.
978-func runList(_ context.Context) error {
979- transport, err := realDial(ipc.SocketDir())
978+func runList(ctx context.Context) error {
979+ transport, err := realDial(ctx, ipc.SocketDir())
980980 if err != nil {
981981 records, loadErr := persist.LoadReadOnly(platform.StateDir())
982982 if loadErr != nil {
@@ -1040,8 +1040,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
10401040 }
10411041
10421042 // runKill asks the daemon to terminate a named session, every session, or the daemon.
1043-func runKill(_ context.Context, name string, all, daemon bool) error {
1044- transport, err := realDial(ipc.SocketDir())
1043+func runKill(ctx context.Context, name string, all, daemon bool) error {
1044+ transport, err := realDial(ctx, ipc.SocketDir())
10451045 if err != nil {
10461046 if name != "" && !all && !daemon {
10471047 storePath := persist.StorePath(platform.StateDir())
@@ -565,8 +565,8 @@ type localDaemonDialer struct {
565 func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {565 func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
566 dial := realDial566 dial := realDial
567 if d.observer != nil {567 if d.observer != nil {
568- dial = func(dir string) (ports.Transport, error) {568+ dial = func(ctx context.Context, dir string) (ports.Transport, error) {
569- return ipc.Dial(dir, ipc.WithRuntimeObserver(d.observer))569+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))
570 }570 }
571 }571 }
572 return ensureDaemon(ctx, d.dir, dial, realSpawn, defaultBackoff)572 return ensureDaemon(ctx, d.dir, dial, realSpawn, defaultBackoff)
@@ -651,7 +651,7 @@ func requestDaemonStop(ctx context.Context) error {
651 651
652 done := make(chan error, 1)652 done := make(chan error, 1)
653 go func() {653 go func() {
654- transport, err := realDial(ipc.SocketDir())654+ transport, err := realDial(ctx, ipc.SocketDir())
655 if err != nil {655 if err != nil {
656 done <- fmt.Errorf("vev: no daemon running")656 done <- fmt.Errorf("vev: no daemon running")
657 return657 return
@@ -704,8 +704,8 @@ func runStdio(ctx context.Context) error {
704 if observerCloser != nil {704 if observerCloser != nil {
705 defer func() { _ = observerCloser.Close() }()705 defer func() { _ = observerCloser.Close() }()
706 }706 }
707- transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(dir string) (ports.Transport, error) {707+ transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
708- return ipc.Dial(dir, ipc.WithRuntimeObserver(observer))708+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
709 }, realSpawn, defaultBackoff)709 }, realSpawn, defaultBackoff)
710 if err != nil {710 if err != nil {
711 return err711 return err
@@ -823,8 +823,8 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
823 if _, err := rand.Read(key); err != nil {823 if _, err := rand.Read(key); err != nil {
824 return err824 return err
825 }825 }
826- daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(dir string) (ports.Transport, error) {826+ daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
827- return ipc.Dial(dir, ipc.WithRuntimeObserver(observer))827+ return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
828 }, realSpawn, defaultBackoff)828 }, realSpawn, defaultBackoff)
829 if err != nil {829 if err != nil {
830 return err830 return err
@@ -975,8 +975,8 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
975 975
976 // runList prints the daemon's session listing. With no daemon running, it976 // runList prints the daemon's session listing. With no daemon running, it
977 // falls back to the persisted stopped-session records.977 // falls back to the persisted stopped-session records.
978-func runList(_ context.Context) error {978+func runList(ctx context.Context) error {
979- transport, err := realDial(ipc.SocketDir())979+ transport, err := realDial(ctx, ipc.SocketDir())
980 if err != nil {980 if err != nil {
981 records, loadErr := persist.LoadReadOnly(platform.StateDir())981 records, loadErr := persist.LoadReadOnly(platform.StateDir())
982 if loadErr != nil {982 if loadErr != nil {
@@ -1040,8 +1040,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
1040 }1040 }
1041 1041
1042 // runKill asks the daemon to terminate a named session, every session, or the daemon.1042 // runKill asks the daemon to terminate a named session, every session, or the daemon.
1043-func runKill(_ context.Context, name string, all, daemon bool) error {1043+func runKill(ctx context.Context, name string, all, daemon bool) error {
1044- transport, err := realDial(ipc.SocketDir())1044+ transport, err := realDial(ctx, ipc.SocketDir())
1045 if err != nil {1045 if err != nil {
1046 if name != "" && !all && !daemon {1046 if name != "" && !all && !daemon {
1047 storePath := persist.StorePath(platform.StateDir())1047 storePath := persist.StorePath(platform.StateDir())
M internal/app/spawn.go +11 -5
@@ -32,7 +32,7 @@ const staleLockAge = 10 * time.Second
3232
3333 // dialFunc dials the daemon socket in dir. Injected so tests can drive the
3434 // spawn/backoff logic without a real socket.
35-type dialFunc func(dir string) (ports.Transport, error)
35+type dialFunc func(ctx context.Context, dir string) (ports.Transport, error)
3636
3737 // spawnFunc launches a detached daemon process. Injected so tests never
3838 // re-exec a real binary.
@@ -58,7 +58,10 @@ var defaultBackoff = backoffConfig{
5858 // spawner via an mkdir lock (losers simply wait), then retry-dials with
5959 // backoff until the socket is live or the budget expires.
6060 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
61- if t, err := dial(dir); err == nil {
61+ if err := ctx.Err(); err != nil {
62+ return nil, err
63+ }
64+ if t, err := dial(ctx, dir); err == nil {
6265 slog.Debug("daemon already reachable", "socket_dir", dir)
6366 return t, nil
6467 }
@@ -120,7 +123,10 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
120123 deadline := time.Now().Add(cfg.total)
121124 backoff := cfg.initial
122125 for {
123- if t, err := dial(dir); err == nil {
126+ if err := ctx.Err(); err != nil {
127+ return nil, err
128+ }
129+ if t, err := dial(ctx, dir); err == nil {
124130 return t, nil
125131 }
126132 if !time.Now().Before(deadline) {
@@ -139,8 +145,8 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
139145 }
140146
141147 // realDial is the production dialer.
142-func realDial(dir string) (ports.Transport, error) {
143- return ipc.Dial(dir)
148+func realDial(ctx context.Context, dir string) (ports.Transport, error) {
149+ return ipc.DialContext(ctx, dir)
144150 }
145151
146152 // realSpawn re-execs this binary as a detached daemon: a new session
@@ -32,7 +32,7 @@ const staleLockAge = 10 * time.Second
32 32
33 // dialFunc dials the daemon socket in dir. Injected so tests can drive the33 // dialFunc dials the daemon socket in dir. Injected so tests can drive the
34 // spawn/backoff logic without a real socket.34 // spawn/backoff logic without a real socket.
35-type dialFunc func(dir string) (ports.Transport, error)35+type dialFunc func(ctx context.Context, dir string) (ports.Transport, error)
36 36
37 // spawnFunc launches a detached daemon process. Injected so tests never37 // spawnFunc launches a detached daemon process. Injected so tests never
38 // re-exec a real binary.38 // re-exec a real binary.
@@ -58,7 +58,10 @@ var defaultBackoff = backoffConfig{
58 // spawner via an mkdir lock (losers simply wait), then retry-dials with58 // spawner via an mkdir lock (losers simply wait), then retry-dials with
59 // backoff until the socket is live or the budget expires.59 // backoff until the socket is live or the budget expires.
60 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {60 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
61- if t, err := dial(dir); err == nil {61+ if err := ctx.Err(); err != nil {
62+ return nil, err
63+ }
64+ if t, err := dial(ctx, dir); err == nil {
62 slog.Debug("daemon already reachable", "socket_dir", dir)65 slog.Debug("daemon already reachable", "socket_dir", dir)
63 return t, nil66 return t, nil
64 }67 }
@@ -120,7 +123,10 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
120 deadline := time.Now().Add(cfg.total)123 deadline := time.Now().Add(cfg.total)
121 backoff := cfg.initial124 backoff := cfg.initial
122 for {125 for {
123- if t, err := dial(dir); err == nil {126+ if err := ctx.Err(); err != nil {
127+ return nil, err
128+ }
129+ if t, err := dial(ctx, dir); err == nil {
124 return t, nil130 return t, nil
125 }131 }
126 if !time.Now().Before(deadline) {132 if !time.Now().Before(deadline) {
@@ -139,8 +145,8 @@ func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig
139 }145 }
140 146
141 // realDial is the production dialer.147 // realDial is the production dialer.
142-func realDial(dir string) (ports.Transport, error) {148+func realDial(ctx context.Context, dir string) (ports.Transport, error) {
143- return ipc.Dial(dir)149+ return ipc.DialContext(ctx, dir)
144 }150 }
145 151
146 // realSpawn re-execs this binary as a detached daemon: a new session152 // realSpawn re-execs this binary as a detached daemon: a new session
M internal/app/spawn_test.go +31 -6
@@ -91,7 +91,7 @@ func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
9191 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
9292 want := portsmocks.NewMockTransport(t)
9393 var calls atomic.Int32
94- dial := func(string) (ports.Transport, error) {
94+ dial := func(context.Context, string) (ports.Transport, error) {
9595 if calls.Add(1) <= 3 {
9696 return nil, errDialFailed
9797 }
@@ -111,7 +111,7 @@ func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
111111 }
112112
113113 func TestRetryDialPermanentFailure(t *testing.T) {
114- dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
114+ dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
115115 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)
116116 if !errors.Is(err, ErrDaemonUnreachable) {
117117 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)
@@ -121,18 +121,43 @@ func TestRetryDialPermanentFailure(t *testing.T) {
121121 func TestRetryDialContextCancelled(t *testing.T) {
122122 ctx, cancel := context.WithCancel(context.Background())
123123 cancel()
124- dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
124+ var calls atomic.Int32
125+ dial := func(context.Context, string) (ports.Transport, error) {
126+ calls.Add(1)
127+ return nil, errDialFailed
128+ }
125129 _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff)
126130 if !errors.Is(err, context.Canceled) {
127131 t.Fatalf("retryDial error = %v, want context.Canceled", err)
128132 }
133+ if calls.Load() != 0 {
134+ t.Fatalf("dial called %d times for canceled context, want 0", calls.Load())
135+ }
136+}
137+
138+func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) {
139+ ctx, cancel := context.WithCancel(context.Background())
140+ cancel()
141+ dial := func(context.Context, string) (ports.Transport, error) {
142+ t.Fatal("ensureDaemon dialed with a canceled context")
143+ return nil, nil
144+ }
145+ spawn := func() error {
146+ t.Fatal("ensureDaemon spawned with a canceled context")
147+ return nil
148+ }
149+
150+ _, err := ensureDaemon(ctx, t.TempDir(), dial, spawn, fastBackoff)
151+ if !errors.Is(err, context.Canceled) {
152+ t.Fatalf("ensureDaemon error = %v, want context.Canceled", err)
153+ }
129154 }
130155
131156 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
132157 dir := filepath.Join(t.TempDir(), "vev")
133158 want := portsmocks.NewMockTransport(t)
134159 var dialCalls, spawnCalls atomic.Int32
135- dial := func(string) (ports.Transport, error) {
160+ dial := func(context.Context, string) (ports.Transport, error) {
136161 // First (pre-spawn) dial fails; the post-spawn retry succeeds.
137162 if dialCalls.Add(1) == 1 {
138163 return nil, errDialFailed
@@ -163,7 +188,7 @@ func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
163188 func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
164189 want := portsmocks.NewMockTransport(t)
165190 var spawnCalls atomic.Int32
166- dial := func(string) (ports.Transport, error) { return want, nil }
191+ dial := func(context.Context, string) (ports.Transport, error) { return want, nil }
167192 spawn := func() error { spawnCalls.Add(1); return nil }
168193
169194 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)
@@ -180,7 +205,7 @@ func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
180205
181206 func TestEnsureDaemonSpawnFailure(t *testing.T) {
182207 spawnErr := errors.New("exec boom")
183- dial := func(string) (ports.Transport, error) { return nil, errDialFailed }
208+ dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
184209 spawn := func() error { return spawnErr }
185210
186211 _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)
@@ -91,7 +91,7 @@ func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
91 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {91 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
92 want := portsmocks.NewMockTransport(t)92 want := portsmocks.NewMockTransport(t)
93 var calls atomic.Int3293 var calls atomic.Int32
94- dial := func(string) (ports.Transport, error) {94+ dial := func(context.Context, string) (ports.Transport, error) {
95 if calls.Add(1) <= 3 {95 if calls.Add(1) <= 3 {
96 return nil, errDialFailed96 return nil, errDialFailed
97 }97 }
@@ -111,7 +111,7 @@ func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
111 }111 }
112 112
113 func TestRetryDialPermanentFailure(t *testing.T) {113 func TestRetryDialPermanentFailure(t *testing.T) {
114- dial := func(string) (ports.Transport, error) { return nil, errDialFailed }114+ dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
115 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)115 _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff)
116 if !errors.Is(err, ErrDaemonUnreachable) {116 if !errors.Is(err, ErrDaemonUnreachable) {
117 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)117 t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err)
@@ -121,18 +121,43 @@ func TestRetryDialPermanentFailure(t *testing.T) {
121 func TestRetryDialContextCancelled(t *testing.T) {121 func TestRetryDialContextCancelled(t *testing.T) {
122 ctx, cancel := context.WithCancel(context.Background())122 ctx, cancel := context.WithCancel(context.Background())
123 cancel()123 cancel()
124- dial := func(string) (ports.Transport, error) { return nil, errDialFailed }124+ var calls atomic.Int32
125+ dial := func(context.Context, string) (ports.Transport, error) {
126+ calls.Add(1)
127+ return nil, errDialFailed
128+ }
125 _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff)129 _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff)
126 if !errors.Is(err, context.Canceled) {130 if !errors.Is(err, context.Canceled) {
127 t.Fatalf("retryDial error = %v, want context.Canceled", err)131 t.Fatalf("retryDial error = %v, want context.Canceled", err)
128 }132 }
133+ if calls.Load() != 0 {
134+ t.Fatalf("dial called %d times for canceled context, want 0", calls.Load())
135+ }
136+}
137+
138+func TestEnsureDaemonContextCancelledDoesNotDialOrSpawn(t *testing.T) {
139+ ctx, cancel := context.WithCancel(context.Background())
140+ cancel()
141+ dial := func(context.Context, string) (ports.Transport, error) {
142+ t.Fatal("ensureDaemon dialed with a canceled context")
143+ return nil, nil
144+ }
145+ spawn := func() error {
146+ t.Fatal("ensureDaemon spawned with a canceled context")
147+ return nil
148+ }
149+
150+ _, err := ensureDaemon(ctx, t.TempDir(), dial, spawn, fastBackoff)
151+ if !errors.Is(err, context.Canceled) {
152+ t.Fatalf("ensureDaemon error = %v, want context.Canceled", err)
153+ }
129 }154 }
130 155
131 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {156 func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
132 dir := filepath.Join(t.TempDir(), "vev")157 dir := filepath.Join(t.TempDir(), "vev")
133 want := portsmocks.NewMockTransport(t)158 want := portsmocks.NewMockTransport(t)
134 var dialCalls, spawnCalls atomic.Int32159 var dialCalls, spawnCalls atomic.Int32
135- dial := func(string) (ports.Transport, error) {160+ dial := func(context.Context, string) (ports.Transport, error) {
136 // First (pre-spawn) dial fails; the post-spawn retry succeeds.161 // First (pre-spawn) dial fails; the post-spawn retry succeeds.
137 if dialCalls.Add(1) == 1 {162 if dialCalls.Add(1) == 1 {
138 return nil, errDialFailed163 return nil, errDialFailed
@@ -163,7 +188,7 @@ func TestEnsureDaemonSpawnsThenDials(t *testing.T) {
163 func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {188 func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
164 want := portsmocks.NewMockTransport(t)189 want := portsmocks.NewMockTransport(t)
165 var spawnCalls atomic.Int32190 var spawnCalls atomic.Int32
166- dial := func(string) (ports.Transport, error) { return want, nil }191+ dial := func(context.Context, string) (ports.Transport, error) { return want, nil }
167 spawn := func() error { spawnCalls.Add(1); return nil }192 spawn := func() error { spawnCalls.Add(1); return nil }
168 193
169 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)194 got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff)
@@ -180,7 +205,7 @@ func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) {
180 205
181 func TestEnsureDaemonSpawnFailure(t *testing.T) {206 func TestEnsureDaemonSpawnFailure(t *testing.T) {
182 spawnErr := errors.New("exec boom")207 spawnErr := errors.New("exec boom")
183- dial := func(string) (ports.Transport, error) { return nil, errDialFailed }208+ dial := func(context.Context, string) (ports.Transport, error) { return nil, errDialFailed }
184 spawn := func() error { return spawnErr }209 spawn := func() error { return spawnErr }
185 210
186 _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)211 _, err := ensureDaemon(context.Background(), filepath.Join(t.TempDir(), "vev"), dial, spawn, fastBackoff)

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