diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78ee4f2e..2d3e5c70 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -81,6 +81,12 @@ jobs: if: runner.os == 'Windows' run: go test -run '^$' ./... + # These checks use in-memory errors and loopback test servers. Run + # the native Winsock refusal case rather than only compiling it. + - name: Windows MCP health + if: runner.os == 'Windows' + run: go test -v -count=1 -timeout 2m ./internal/library -run '^(TestNetHealth|TestCheckHTTP)$' + # Usage isolates USERPROFILE and the agent homes; execute its native # ChangeTime checks rather than only cross-compiling the Windows API. - name: Windows usage cache diff --git a/internal/library/mcp_check.go b/internal/library/mcp_check.go index 49eecf06..df4a4640 100644 --- a/internal/library/mcp_check.go +++ b/internal/library/mcp_check.go @@ -15,7 +15,6 @@ import ( "os/exec" "strings" "sync" - "syscall" "time" "github.com/yetone/magpie/internal/gateway" @@ -403,7 +402,7 @@ func netHealth(ctx context.Context, err error) ServerHealth { if ctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) { return timedOut() } - if errors.Is(err, syscall.ECONNREFUSED) { + if errors.Is(err, connectionRefused) { return failed("refused", err.Error()) } var ue *url.Error diff --git a/internal/library/mcp_check_net_other.go b/internal/library/mcp_check_net_other.go new file mode 100644 index 00000000..4c4f89f5 --- /dev/null +++ b/internal/library/mcp_check_net_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package library + +import "syscall" + +const connectionRefused = syscall.ECONNREFUSED diff --git a/internal/library/mcp_check_net_test.go b/internal/library/mcp_check_net_test.go new file mode 100644 index 00000000..69fd96f3 --- /dev/null +++ b/internal/library/mcp_check_net_test.go @@ -0,0 +1,45 @@ +package library + +import ( + "context" + "errors" + "net" + "net/url" + "os" + "testing" +) + +func TestNetHealth(t *testing.T) { + wrapped := &url.Error{ + Op: "Post", URL: "http://127.0.0.1/mcp", + Err: &net.OpError{Op: "dial", Net: "tcp", Err: os.NewSyscallError("connect", connectionRefused)}, + } + for _, test := range []struct { + name string + err error + why string + }{ + {"native refusal", connectionRefused, "refused"}, + {"wrapped refusal", wrapped, "refused"}, + {"deadline", &url.Error{Op: "Post", Err: context.DeadlineExceeded}, "timeout"}, + {"dns", &url.Error{Op: "Post", Err: &net.DNSError{Name: "missing.invalid", IsNotFound: true}}, "unreachable"}, + {"other error", errors.New("network unavailable"), "unreachable"}, + {"text alone", errors.New("connection refused"), "unreachable"}, + } { + t.Run(test.name, func(t *testing.T) { + h := netHealth(context.Background(), test.err) + if h.State != "error" || h.Why != test.why { + t.Fatalf("got %+v, want error/%s", h, test.why) + } + if test.why == "refused" && h.Detail != test.err.Error() { + t.Fatalf("lost the original error: %q, want %q", h.Detail, test.err.Error()) + } + }) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if h := netHealth(ctx, wrapped); h.Why != "timeout" { + t.Fatalf("an ended check should keep its timeout classification: %+v", h) + } +} diff --git a/internal/library/mcp_check_net_windows.go b/internal/library/mcp_check_net_windows.go new file mode 100644 index 00000000..e31405b1 --- /dev/null +++ b/internal/library/mcp_check_net_windows.go @@ -0,0 +1,7 @@ +package library + +import "golang.org/x/sys/windows" + +// TCP failures carry Winsock errors. syscall.ECONNREFUSED is a different, +// compatibility errno on Windows and does not match a refused connection. +const connectionRefused = windows.WSAECONNREFUSED