Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions internal/shards/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ func (d *Daemon) Run(ctx context.Context) error {
go d.listenUDP(ctx, udpReady)
if err := <-udpReady; err != nil {
if !d.cfg.FSWatch {
if errors.Is(err, syscall.EADDRINUSE) {
return fmt.Errorf("UDP port %d already in use — is `supermodel watch` already running?", d.cfg.NotifyPort)
if isAddrInUse(err) {
return fmt.Errorf("supermodel is already watching this project in another terminal — your graph is being kept up to date\nRun 'supermodel status' to check, or press Ctrl+C in the other terminal to stop")
}
return fmt.Errorf("failed to start UDP listener on port %d: %w", d.cfg.NotifyPort, err)
}
Expand Down Expand Up @@ -749,6 +749,17 @@ func daemonSortedKeys(m map[string]bool) []string {
return keys
}

// isAddrInUse reports whether err indicates that a network address is already
// in use. It checks syscall.EADDRINUSE (POSIX) and, for Windows, inspects the
// error message because Windows uses a different underlying error code.
func isAddrInUse(err error) bool {
if errors.Is(err, syscall.EADDRINUSE) {
return true
}
// Windows returns WSAEADDRINUSE; its message contains this substring.
return strings.Contains(err.Error(), "Only one usage of each socket address")
}

func newUUID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
Expand Down
63 changes: 63 additions & 0 deletions internal/shards/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package shards

import (
"context"
"net"
"os"
"strings"
"testing"

Expand Down Expand Up @@ -963,3 +965,64 @@ func TestOnSyncing_NilSafe(t *testing.T) {
}
d.incrementalUpdate(context.Background(), []string{"a.go"})
}

// ── Port-conflict UX ─────────────────────────────────────────────────────────

// TestPortConflict_FriendlyMessage verifies that when the daemon cannot bind
// its UDP notify port (because another supermodel instance is already running),
// the returned error message is friendly and informative rather than a raw
// OS error. It should tell the user their graph is already being watched and
// NOT just say "already in use".
func TestPortConflict_FriendlyMessage(t *testing.T) {
// Bind the notify port first to simulate a running instance.
blocker, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("could not bind blocker socket: %v", err)
}
defer blocker.Close()
port := blocker.LocalAddr().(*net.UDPAddr).Port

// Write a minimal valid cache file so loadOrGenerate succeeds without an API call.
repoDir := t.TempDir()
cacheDir := repoDir + "/.supermodel"
if mkErr := os.MkdirAll(cacheDir, 0o755); mkErr != nil {
t.Fatalf("mkdir: %v", mkErr)
}
cacheFile := cacheDir + "/cache.json"
minimalIR := `{"graph":{"nodes":[{"id":"n1","labels":["File"],"properties":{"filePath":"/fake/file.go"}}],"relationships":[]}}`
if writeErr := os.WriteFile(cacheFile, []byte(minimalIR), 0o644); writeErr != nil {
t.Fatalf("write cache: %v", writeErr)
}

cfg := DaemonConfig{
RepoDir: repoDir,
CacheFile: cacheFile,
NotifyPort: port,
FSWatch: false, // FSWatch=false means EADDRINUSE is fatal
LogFunc: func(string, ...interface{}) {},
}
d := &Daemon{
cfg: cfg,
client: &mockAnalyzeClient{result: buildIR(nil, nil)},
cache: NewCache(),
logf: func(string, ...interface{}) {},
notifyCh: make(chan string, 256),
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

runErr := d.Run(ctx)
if runErr == nil {
t.Fatal("expected an error when port is already bound, got nil")
}

msg := runErr.Error()

// The message must NOT be just a raw "already in use" OS error — it should be
// friendly and tell the user what's happening.
if !strings.Contains(msg, "already watching") && !strings.Contains(msg, "another terminal") {
t.Errorf("error message is not user-friendly; got: %q\n"+
"want: message containing \"already watching\" or \"another terminal\"", msg)
}
}
Loading