Skip to content

Commit bd1e2ad

Browse files
greynewellclaude
andauthored
fix: friendlier error when watch is already running on port 7734 (#165)
* test: failing test for port-conflict friendly error message (#156) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: improve port-conflict error to tell user watch is already running Closes #156. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: improve port-conflict error to tell user watch is already running Closes #156. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 518c2bc commit bd1e2ad

2 files changed

Lines changed: 76 additions & 2 deletions

File tree

internal/shards/daemon.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ func (d *Daemon) Run(ctx context.Context) error {
9898
go d.listenUDP(ctx, udpReady)
9999
if err := <-udpReady; err != nil {
100100
if !d.cfg.FSWatch {
101-
if errors.Is(err, syscall.EADDRINUSE) {
102-
return fmt.Errorf("UDP port %d already in use — is `supermodel watch` already running?", d.cfg.NotifyPort)
101+
if isAddrInUse(err) {
102+
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")
103103
}
104104
return fmt.Errorf("failed to start UDP listener on port %d: %w", d.cfg.NotifyPort, err)
105105
}
@@ -749,6 +749,17 @@ func daemonSortedKeys(m map[string]bool) []string {
749749
return keys
750750
}
751751

752+
// isAddrInUse reports whether err indicates that a network address is already
753+
// in use. It checks syscall.EADDRINUSE (POSIX) and, for Windows, inspects the
754+
// error message because Windows uses a different underlying error code.
755+
func isAddrInUse(err error) bool {
756+
if errors.Is(err, syscall.EADDRINUSE) {
757+
return true
758+
}
759+
// Windows returns WSAEADDRINUSE; its message contains this substring.
760+
return strings.Contains(err.Error(), "Only one usage of each socket address")
761+
}
762+
752763
func newUUID() string {
753764
b := make([]byte, 16)
754765
if _, err := rand.Read(b); err != nil {

internal/shards/daemon_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package shards
22

33
import (
44
"context"
5+
"net"
6+
"os"
57
"strings"
68
"testing"
79

@@ -963,3 +965,64 @@ func TestOnSyncing_NilSafe(t *testing.T) {
963965
}
964966
d.incrementalUpdate(context.Background(), []string{"a.go"})
965967
}
968+
969+
// ── Port-conflict UX ─────────────────────────────────────────────────────────
970+
971+
// TestPortConflict_FriendlyMessage verifies that when the daemon cannot bind
972+
// its UDP notify port (because another supermodel instance is already running),
973+
// the returned error message is friendly and informative rather than a raw
974+
// OS error. It should tell the user their graph is already being watched and
975+
// NOT just say "already in use".
976+
func TestPortConflict_FriendlyMessage(t *testing.T) {
977+
// Bind the notify port first to simulate a running instance.
978+
blocker, err := net.ListenPacket("udp", "127.0.0.1:0")
979+
if err != nil {
980+
t.Fatalf("could not bind blocker socket: %v", err)
981+
}
982+
defer blocker.Close()
983+
port := blocker.LocalAddr().(*net.UDPAddr).Port
984+
985+
// Write a minimal valid cache file so loadOrGenerate succeeds without an API call.
986+
repoDir := t.TempDir()
987+
cacheDir := repoDir + "/.supermodel"
988+
if mkErr := os.MkdirAll(cacheDir, 0o755); mkErr != nil {
989+
t.Fatalf("mkdir: %v", mkErr)
990+
}
991+
cacheFile := cacheDir + "/cache.json"
992+
minimalIR := `{"graph":{"nodes":[{"id":"n1","labels":["File"],"properties":{"filePath":"/fake/file.go"}}],"relationships":[]}}`
993+
if writeErr := os.WriteFile(cacheFile, []byte(minimalIR), 0o644); writeErr != nil {
994+
t.Fatalf("write cache: %v", writeErr)
995+
}
996+
997+
cfg := DaemonConfig{
998+
RepoDir: repoDir,
999+
CacheFile: cacheFile,
1000+
NotifyPort: port,
1001+
FSWatch: false, // FSWatch=false means EADDRINUSE is fatal
1002+
LogFunc: func(string, ...interface{}) {},
1003+
}
1004+
d := &Daemon{
1005+
cfg: cfg,
1006+
client: &mockAnalyzeClient{result: buildIR(nil, nil)},
1007+
cache: NewCache(),
1008+
logf: func(string, ...interface{}) {},
1009+
notifyCh: make(chan string, 256),
1010+
}
1011+
1012+
ctx, cancel := context.WithCancel(context.Background())
1013+
defer cancel()
1014+
1015+
runErr := d.Run(ctx)
1016+
if runErr == nil {
1017+
t.Fatal("expected an error when port is already bound, got nil")
1018+
}
1019+
1020+
msg := runErr.Error()
1021+
1022+
// The message must NOT be just a raw "already in use" OS error — it should be
1023+
// friendly and tell the user what's happening.
1024+
if !strings.Contains(msg, "already watching") && !strings.Contains(msg, "another terminal") {
1025+
t.Errorf("error message is not user-friendly; got: %q\n"+
1026+
"want: message containing \"already watching\" or \"another terminal\"", msg)
1027+
}
1028+
}

0 commit comments

Comments
 (0)