feat(02-03): client socket, autostart daemon, switch handler, workspace wiring

- Client struct sends JSON requests to daemon over Unix socket
- EnsureDaemon auto-starts the daemon if not running (retry 50ms x 20)
- Switch handler uses FuzzyMatch + SwitchToWorkspace via i3 IPC
- InitWorkspaceResolver wires BuildTerminalWorkspaceMap + ResolveWorkspace
- sysattr_linux.go for Setsid detach on daemon spawn
This commit is contained in:
Pierre Martin
2026-03-23 17:51:31 +01:00
parent a388c9477d
commit a79a0e154c
4 changed files with 340 additions and 0 deletions

84
client.go Normal file
View File

@@ -0,0 +1,84 @@
package main
import (
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"time"
)
// Client communicates with the vmux daemon over a Unix socket.
type Client struct {
sockPath string
}
// NewClient creates a client targeting the given socket path.
func NewClient(sockPath string) *Client {
return &Client{sockPath: sockPath}
}
// Send transmits a Request to the daemon and returns the Response.
func (c *Client) Send(req Request) (*Response, error) {
conn, err := net.DialTimeout("unix", c.sockPath, 2*time.Second)
if err != nil {
return nil, fmt.Errorf("dial daemon: %w", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(5 * time.Second))
if err := json.NewEncoder(conn).Encode(req); err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
var resp Response
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return &resp, nil
}
// EnsureDaemon checks if a daemon is already running on sockPath.
// If not, it spawns one in the background and waits until it's reachable.
func EnsureDaemon(sockPath string) error {
// Already running?
conn, err := net.DialTimeout("unix", sockPath, 200*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
// Spawn daemon in background
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve executable: %w", err)
}
cmd := exec.Command(exe, "daemon")
cmd.SysProcAttr = newSysProcAttr()
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
if err := cmd.Start(); err != nil {
return fmt.Errorf("start daemon: %w", err)
}
// Detach: don't wait for the child
go cmd.Wait()
// Retry until daemon is reachable (50ms x 20 = 1s max)
for i := 0; i < 20; i++ {
time.Sleep(50 * time.Millisecond)
conn, err := net.DialTimeout("unix", sockPath, 200*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
}
return fmt.Errorf("daemon did not start within 1s")
}

207
client_test.go Normal file
View File

@@ -0,0 +1,207 @@
package main
import (
"encoding/json"
"net"
"path/filepath"
"testing"
"time"
i3 "go.i3wm.org/i3/v4"
)
func TestClientSendReceive(t *testing.T) {
d := newTestDaemon(t)
if err := d.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer d.Stop()
// Populate a session
d.registry.Update(SessionInfo{
PID: 42,
SessionID: "test-sess",
State: "Working",
Cwd: "/tmp/test",
})
client := NewClient(d.sockPath)
resp, err := client.Send(Request{Action: "list"})
if err != nil {
t.Fatalf("send: %v", err)
}
if !resp.OK {
t.Fatalf("resp.OK = false, error = %q", resp.Error)
}
if len(resp.Sessions) != 1 {
t.Fatalf("sessions len = %d, want 1", len(resp.Sessions))
}
if resp.Sessions[0].SessionID != "test-sess" {
t.Errorf("session_id = %q, want %q", resp.Sessions[0].SessionID, "test-sess")
}
}
func TestEnsureDaemonAlreadyRunning(t *testing.T) {
d := newTestDaemon(t)
if err := d.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer d.Stop()
// EnsureDaemon should return nil immediately since daemon is already listening
err := EnsureDaemon(d.sockPath)
if err != nil {
t.Errorf("EnsureDaemon should succeed when daemon is running: %v", err)
}
}
func TestEnsureDaemonNoSocket(t *testing.T) {
dir := t.TempDir()
sockPath := filepath.Join(dir, "nonexistent.sock")
// EnsureDaemon with no real daemon and a fake executable will fail,
// but we can verify it doesn't panic and returns an error
err := EnsureDaemon(sockPath)
if err == nil {
t.Error("EnsureDaemon should fail when no daemon can be started")
}
}
func TestClientSendSwitchNoMatch(t *testing.T) {
d := newTestDaemon(t)
if err := d.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer d.Stop()
client := NewClient(d.sockPath)
args := mustMarshalJSON(t, SwitchArgs{Query: "nonexistent"})
resp, err := client.Send(Request{Action: "switch", Args: args})
if err != nil {
t.Fatalf("send: %v", err)
}
if resp.OK {
t.Error("expected OK=false for no matching session")
}
if resp.Error == "" {
t.Error("expected error message")
}
}
func TestClientSendSwitchNoWorkspace(t *testing.T) {
d := newTestDaemon(t)
if err := d.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer d.Stop()
d.registry.Update(SessionInfo{
PID: 42,
SessionID: "sess-1",
State: "Working",
Cwd: "/tmp/test",
GitBranch: "feat-auth",
Workspace: "", // no workspace
})
client := NewClient(d.sockPath)
args := mustMarshalJSON(t, SwitchArgs{Query: "auth"})
resp, err := client.Send(Request{Action: "switch", Args: args})
if err != nil {
t.Fatalf("send: %v", err)
}
if resp.OK {
t.Error("expected OK=false when session has no workspace")
}
}
func TestClientConnectionRefused(t *testing.T) {
dir := t.TempDir()
sockPath := filepath.Join(dir, "dead.sock")
client := NewClient(sockPath)
_, err := client.Send(Request{Action: "list"})
if err == nil {
t.Error("expected error when daemon is not running")
}
}
func TestClientSendSwitchWithMockI3(t *testing.T) {
d := newTestDaemon(t)
d.i3commander = &mockI3Commander{results: []i3.CommandResult{{Success: true}}}
if err := d.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer d.Stop()
d.registry.Update(SessionInfo{
PID: 42,
SessionID: "sess-1",
State: "Working",
Cwd: "/tmp/vmux-project",
GitBranch: "feat-auth",
Workspace: "3",
})
client := NewClient(d.sockPath)
args := mustMarshalJSON(t, SwitchArgs{Query: "auth"})
resp, err := client.Send(Request{Action: "switch", Args: args})
if err != nil {
t.Fatalf("send: %v", err)
}
if !resp.OK {
t.Errorf("expected OK=true, got error: %q", resp.Error)
}
// Verify workspace was switched (SwitchToWorkspace sends "workspace number 3")
mock := d.i3commander.(*mockI3Commander)
want := "workspace number 3"
if mock.lastCommand != want {
t.Errorf("lastCommand = %q, want %q", mock.lastCommand, want)
}
}
// mockI3Commander is defined in i3bridge_test.go (same package).
func mustMarshalJSON(t *testing.T, v any) []byte {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return data
}
// Verify Client respects timeouts on dead connections
func TestClientTimeout(t *testing.T) {
dir := t.TempDir()
sockPath := filepath.Join(dir, "slow.sock")
// Create a listener that accepts but never responds
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
// Hold the connection open without responding
time.Sleep(10 * time.Second)
conn.Close()
}()
client := NewClient(sockPath)
_, err = client.Send(Request{Action: "list"})
if err == nil {
t.Error("expected timeout error")
}
}

View File

@@ -151,6 +151,7 @@ type Daemon struct {
procDir string procDir string
claudeDir string claudeDir string
workspaceResolver func(claudePID int) string // nil = no workspace resolution workspaceResolver func(claudePID int) string // nil = no workspace resolution
i3commander I3Commander
pollInterval time.Duration pollInterval time.Duration
stopCh chan struct{} stopCh chan struct{}
listener net.Listener listener net.Listener
@@ -169,6 +170,21 @@ func NewDaemon(sockPath, procDir, claudeDir string, labels *LabelStore) *Daemon
} }
} }
// InitWorkspaceResolver sets up i3+X11 workspace resolution.
// If i3 or X11 is unavailable, logs a warning and continues without workspace info.
func (d *Daemon) InitWorkspaceResolver(treeProvider I3TreeProvider, x11 X11PIDResolver) {
if treeProvider == nil || x11 == nil {
return
}
d.workspaceResolver = func(claudePID int) string {
termMap, err := BuildTerminalWorkspaceMap(treeProvider, x11)
if err != nil {
return ""
}
return ResolveWorkspace(d.procDir, claudePID, termMap)
}
}
// Start runs the daemon: initial scan, then listens on the Unix socket // Start runs the daemon: initial scan, then listens on the Unix socket
// and polls for sessions in the background. // and polls for sessions in the background.
func (d *Daemon) Start() error { func (d *Daemon) Start() error {
@@ -329,6 +345,32 @@ func (d *Daemon) handleConnection(conn net.Conn) {
d.registry.mu.Unlock() d.registry.mu.Unlock()
writeResponse(conn, Response{OK: true}) writeResponse(conn, Response{OK: true})
case "switch":
var args SwitchArgs
if err := json.Unmarshal(req.Args, &args); err != nil {
writeResponse(conn, Response{Error: "invalid switch args: " + err.Error()})
return
}
sessions := d.registry.List()
match := FuzzyMatch(args.Query, sessions)
if match == nil {
writeResponse(conn, Response{Error: "no session matching: " + args.Query})
return
}
if match.Workspace == "" {
writeResponse(conn, Response{Error: "no workspace for session " + match.SessionID})
return
}
if d.i3commander == nil {
writeResponse(conn, Response{Error: "i3 commander not available"})
return
}
if err := SwitchToWorkspace(d.i3commander, match.Workspace); err != nil {
writeResponse(conn, Response{Error: "switch workspace: " + err.Error()})
return
}
writeResponse(conn, Response{OK: true})
case "stop": case "stop":
writeResponse(conn, Response{OK: true}) writeResponse(conn, Response{OK: true})
d.Stop() d.Stop()

7
sysattr_linux.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "syscall"
func newSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setsid: true}
}