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:
84
client.go
Normal file
84
client.go
Normal 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
207
client_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
42
daemon.go
42
daemon.go
@@ -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
7
sysattr_linux.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "syscall"
|
||||||
|
|
||||||
|
func newSysProcAttr() *syscall.SysProcAttr {
|
||||||
|
return &syscall.SysProcAttr{Setsid: true}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user