Repository: viamrobotics/rdk
Pull request #4878: APP-7464 support unix sockets on windows OS
Author: login: abe-winter
Labels: name: safe to test
description: This pull request is marked safe to test from a trusted zone
Created: 2025-03-28T18:49:27Z | Merged: 2025-03-28T21:10:42Z

## What changed
- add cleanWindowsUDS function to clean unix socket paths on windows hosts, so they're compatible w/ the grpc library
- still respect VIAM_TCP_SOCKETS env var, but don't force-activate it on goos=windows
## Why
The `url.Parse` call in the (3rd-party) grpc library, [here](https://github.com/grpc/grpc-go/blob/v1.71.0/clientconn.go#L1720-L1727), as well as its direct caller, become unhappy in various ways when given windows-looking filesystem paths. This PR works around that by formatting the paths in a way it likes.

To see this fail in the small, do:

```golang
urls := []string{
	"unix://C:\\hello\\whatever.sock",
	"unix://\\hello\\whatever.sock",
	"unix://C:/hello/whatever.sock",
	"unix:///hello/whatever.sock",
}
for i, raw := range urls {
	if u, err := url.Parse(raw); err != nil {
		println("err", i, err.Error())
	} else {
		println("result", i, u.String())
	}
}
```

(The first two fail, the last two succeed).

I'm stripping the `C:` prefix as well because I get some kind of 'invalid authority' error from grpc when I pass that.
## Notes
### Future work
The `VIAM_TCP_SOCKETS` flag is still respected when present. I think we should remove this feature completely.
### Compatibility with existing windows modules
This seems to work with the windows modules that are currently on the registry and which we've tested in TCP mode. Will test with a few more modules before merging.
## Pre-merge
- [x] clearer comment with some background
- [x] safer prefix trimming


Diff:
diff --git a/cli/module_registry.go b/cli/module_registry.go
index 7a02adf285b..29491305189 100644
--- a/cli/module_registry.go
+++ b/cli/module_registry.go
@@ -862,7 +862,10 @@ func readModels(path string, logger logging.Logger) ([]ModuleComponent, error) {
 		ExePath: path,
 	}
 
-	mgr := modmanager.NewManager(context.Background(), parentAddr, logger, modmanageroptions.Options{UntrustedEnv: false})
+	mgr, err := modmanager.NewManager(context.Background(), parentAddr, logger, modmanageroptions.Options{UntrustedEnv: false})
+	if err != nil {
+		return nil, err
+	}
 	defer vutils.UncheckedErrorFunc(func() error { return mgr.Close(context.Background()) })
 
 	err = mgr.Add(context.TODO(), cfg)
diff --git a/module/modmanager/manager.go b/module/modmanager/manager.go
index d72e49b23ff..bd0dc4e401b 100644
--- a/module/modmanager/manager.go
+++ b/module/modmanager/manager.go
@@ -7,6 +7,8 @@ import (
 	"io/fs"
 	"os"
 	"path/filepath"
+	"regexp"
+	"runtime"
 	"slices"
 	"strconv"
 	"strings"
@@ -53,12 +55,17 @@ var (
 	// name of the folder under the viamHomeDir that holds all the folders for the module data
 	// ex: /home/walle/.viam/module-data/<cloud-robot-id>/<module-name>
 	parentModuleDataFolderName = "module-data"
+	windowsPathRegex           = regexp.MustCompile(`^(\w:)?(.+)$`)
 )
 
 // NewManager returns a Manager.
 func NewManager(
 	ctx context.Context, parentAddr string, logger logging.Logger, options modmanageroptions.Options,
-) modmaninterface.ModuleManager {
+) (modmaninterface.ModuleManager, error) {
+	parentAddr, err := cleanWindowsSocketPath(runtime.GOOS, parentAddr)
+	if err != nil {
+		return nil, err
+	}
 	restartCtx, restartCtxCancel := context.WithCancel(ctx)
 	ret := &Manager{
 		logger:                  logger.Sublogger("modmanager"),
@@ -76,7 +83,7 @@ func NewManager(
 		modPeerConnTracker:      options.ModPeerConnTracker,
 	}
 	ret.nextPort.Store(tcpPortRange)
-	return ret
+	return ret, nil
 }
 
 type module struct {
@@ -1067,7 +1074,7 @@ func (m *module) dial() error {
 	var err error
 	addrToDial := m.addr
 	if !rutils.TCPRegex.MatchString(addrToDial) {
-		addrToDial = "unix://" + m.addr
+		addrToDial = "unix://" + addrToDial
 	}
 	conn, err := grpc.Dial( //nolint:staticcheck
 		addrToDial,
@@ -1170,6 +1177,24 @@ func (mgr *Manager) FirstRun(ctx context.Context, conf config.Module) error {
 	return conf.FirstRun(ctx, pkgsDir, dataDir, env, mgr.logger)
 }
 
+// On windows only, this mutates socket paths so they work well with the GRPC library.
+// It converts e.g. C:\x\y.sock to /x/y.sock
+// If you don't do this, it will confuse grpc-go's url.Parse call and surrounding logic.
+// See https://github.com/grpc/grpc-go/blob/v1.71.0/clientconn.go#L1720-L1727
+func cleanWindowsSocketPath(goos, orig string) (string, error) {
+	if goos == "windows" {
+		match := windowsPathRegex.FindStringSubmatch(orig)
+		if match == nil {
+			return "", fmt.Errorf("error cleaning socket path %s", orig)
+		}
+		if match[1] != "" && strings.ToLower(match[1]) != "c:" {
+			return "", fmt.Errorf("we expect unix sockets on C: drive, not %s", match[1])
+		}
+		return strings.ReplaceAll(match[2], "\\", "/"), nil
+	}
+	return orig, nil
+}
+
 func (m *module) startProcess(
 	ctx context.Context,
 	parentAddr string,
@@ -1188,6 +1213,10 @@ func (m *module) startProcess(
 			filepath.Dir(parentAddr), fmt.Sprintf("%s-%s", m.cfg.Name, utils.RandomAlphaString(5))); err != nil {
 			return err
 		}
+		m.addr, err = cleanWindowsSocketPath(runtime.GOOS, m.addr)
+		if err != nil {
+			return err
+		}
 	}
 
 	// We evaluate the Module's ExePath absolutely in the viam-server process so that
diff --git a/module/modmanager/manager_test.go b/module/modmanager/manager_test.go
index 510f5fcc726..554c1b4560a 100644
--- a/module/modmanager/manager_test.go
+++ b/module/modmanager/manager_test.go
@@ -65,7 +65,8 @@ func setupModManager(
 	options modmanageroptions.Options,
 ) modmaninterface.ModuleManager {
 	t.Helper()
-	mgr := NewManager(ctx, parentAddr, logger, options)
+	mgr, err := NewManager(ctx, parentAddr, logger, options)
+	test.That(t, err, test.ShouldBeNil)
 	t.Cleanup(func() {
 		// Wait for module recovery processes here because modmanager.Close does not.
 		// Do so by grabbing a copy of the modules and then waiting after
@@ -1159,7 +1160,7 @@ func TestRTPPassthrough(t *testing.T) {
 	parentAddr := setupSocketWithRobot(t)
 
 	greenLog(t, "test AddModule")
-	mgr := NewManager(ctx, parentAddr, logger, modmanageroptions.Options{UntrustedEnv: false})
+	mgr, err := NewManager(ctx, parentAddr, logger, modmanageroptions.Options{UntrustedEnv: false})
 	test.That(t, err, test.ShouldBeNil)
 
 	// add module executable
@@ -1365,7 +1366,8 @@ func TestAddStreamMaxTrackErr(t *testing.T) {
 	parentAddr := setupSocketWithRobot(t)
 
 	greenLog(t, "test AddModule")
-	mgr := NewManager(ctx, parentAddr, logger, modmanageroptions.Options{UntrustedEnv: false})
+	mgr, err := NewManager(ctx, parentAddr, logger, modmanageroptions.Options{UntrustedEnv: false})
+	test.That(t, err, test.ShouldBeNil)
 	defer func() {
 		test.That(t, mgr.Close(ctx), test.ShouldBeNil)
 	}()
@@ -1375,7 +1377,7 @@ func TestAddStreamMaxTrackErr(t *testing.T) {
 		Name:    "rtp-passthrough-module",
 		ExePath: modPath,
 	}
-	err := mgr.Add(ctx, modCfg)
+	err = mgr.Add(ctx, modCfg)
 	test.That(t, err, test.ShouldBeNil)
 
 	reg, ok := resource.LookupRegistration(camera.API, model)
@@ -1695,3 +1697,30 @@ func TestFirstRun(t *testing.T) {
 		test.That(t, err, test.ShouldResemble, context.DeadlineExceeded)
 	})
 }
+
+func TestCleanWindowsSocketPath(t *testing.T) {
+	// uppercase and lowercase
+	clean, err := cleanWindowsSocketPath("windows", "C:\\x\\y.sock")
+	test.That(t, err, test.ShouldBeNil)
+	test.That(t, clean, test.ShouldResemble, "/x/y.sock")
+	clean, err = cleanWindowsSocketPath("windows", "c:\\x\\y.sock")
+	test.That(t, err, test.ShouldBeNil)
+	test.That(t, clean, test.ShouldResemble, "/x/y.sock")
+
+	// wrong disk
+	_, err = cleanWindowsSocketPath("windows", "d:\\x\\y.sock")
+	test.That(t, err, test.ShouldNotBeNil)
+
+	// no disk
+	clean, err = cleanWindowsSocketPath("windows", "\\x\\y.sock")
+	test.That(t, err, test.ShouldBeNil)
+	test.That(t, clean, test.ShouldResemble, "/x/y.sock")
+	clean, err = cleanWindowsSocketPath("windows", "/x/y.sock")
+	test.That(t, err, test.ShouldBeNil)
+	test.That(t, clean, test.ShouldResemble, "/x/y.sock")
+
+	// linux
+	clean, err = cleanWindowsSocketPath("linux", "/x/y.sock")
+	test.That(t, err, test.ShouldBeNil)
+	test.That(t, clean, test.ShouldResemble, "/x/y.sock")
+}
diff --git a/robot/impl/local_robot.go b/robot/impl/local_robot.go
index c2e9bb397f3..90ec81a9043 100644
--- a/robot/impl/local_robot.go
+++ b/robot/impl/local_robot.go
@@ -505,7 +505,7 @@ func newWithResources(
 		homeDir = rOpts.viamHomeDir
 	}
 	// Once web service is started, start module manager
-	r.manager.startModuleManager(
+	if err := r.manager.startModuleManager(
 		closeCtx,
 		r.webSvc.ModuleAddress(),
 		r.removeOrphanedResources,
@@ -515,7 +515,9 @@ func newWithResources(
 		logger,
 		cfg.PackagePath,
 		r.webSvc.ModPeerConnTracker(),
-	)
+	); err != nil {
+		return nil, err
+	}
 
 	if !rOpts.disableCompleteConfigWorker {
 		r.activeBackgroundWorkers.Add(1)
diff --git a/robot/impl/resource_manager.go b/robot/impl/resource_manager.go
index 0ee3b710a2b..0dfb4fe40e4 100644
--- a/robot/impl/resource_manager.go
+++ b/robot/impl/resource_manager.go
@@ -124,7 +124,7 @@ func (manager *resourceManager) startModuleManager(
 	logger logging.Logger,
 	packagesDir string,
 	modPeerConnTracker *grpc.ModPeerConnTracker,
-) {
+) error {
 	mmOpts := modmanageroptions.Options{
 		UntrustedEnv:            untrustedEnv,
 		RemoveOrphanedResources: removeOrphanedResources,
@@ -134,10 +134,14 @@ func (manager *resourceManager) startModuleManager(
 		FTDC:                    manager.opts.ftdc,
 		ModPeerConnTracker:      modPeerConnTracker,
 	}
-	modmanager := modmanager.NewManager(ctx, parentAddr, logger, mmOpts)
+	modmanager, err := modmanager.NewManager(ctx, parentAddr, logger, mmOpts)
+	if err != nil {
+		return err
+	}
 	manager.modManagerLock.Lock()
 	manager.moduleManager = modmanager
 	manager.modManagerLock.Unlock()
+	return nil
 }
 
 // addRemote adds a remote to the manager.
diff --git a/utils/env.go b/utils/env.go
index 1e82cf06222..093c4d5bf91 100644
--- a/utils/env.go
+++ b/utils/env.go
@@ -122,8 +122,7 @@ func ViamTCPSockets() bool {
 	// note: unix sockets have been supported on windows for a while, but go-grpc does not support them.
 	// 2017 support announcement: https://devblogs.microsoft.com/commandline/af_unix-comes-to-windows/
 	// go grpc client bug on win: https://github.com/dotnet/aspnetcore/issues/47043
-	return runtime.GOOS == "windows" ||
-		slices.Contains(EnvTrueValues, os.Getenv("VIAM_TCP_SOCKETS"))
+	return slices.Contains(EnvTrueValues, os.Getenv("VIAM_TCP_SOCKETS"))
 }
 
 // LogViamEnvVariables logs the list of viam environment variables in [os.Environ] along with the env passed in.


Reviews:
user: login: dgottlieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T19:34:41Z
user: login: abe-winter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T20:14:41Z
user: login: abe-winter
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T20:15:17Z
user: login: dgottlieb
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-28T20:27:36Z

Review comments:
diff_hunk: @@ -1170,6 +1171,16 @@ func (mgr *Manager) FirstRun(ctx context.Context, conf config.Module) error {
 	return conf.FirstRun(ctx, pkgsDir, dataDir, env, mgr.logger)
 }
 
+// On windows only, this mutates socket paths so they work well with the GRPC library.
+// If you don't do this, it will confuse grpc-go's url.Parse call and surrounding logic.
+// See https://github.com/grpc/grpc-go/blob/v1.71.0/clientconn.go#L1720-L1727
+func cleanWindowsSocketPath(orig string) string {
path: module/modmanager/manager.go
user: login: dgottlieb
body: It seems like this method expects inputs (from windows) such as:
```
C:\root\viam
```
And we want to mutate that into:
```
/root/viam
```
?

Can we add an example like that as a comment to help guide the reader?
created_at: 2025-03-28T19:32:26Z
author_association: MEMBER
diff_hunk: @@ -1067,7 +1068,7 @@ func (m *module) dial() error {
 	var err error
 	addrToDial := m.addr
 	if !rutils.TCPRegex.MatchString(addrToDial) {
-		addrToDial = "unix://" + m.addr
+		addrToDial = "unix://" + cleanWindowsSocketPath(addrToDial)
path: module/modmanager/manager.go
user: login: dgottlieb
body: I see that the `m.addr` variable is "already cleaned" during [this assignment](https://github.com/viamrobotics/rdk/pull/4878/files#diff-a4308b77f384da81096eda21bf023471e80818dda86e74f9335f99a6a57d07c9R1202).

Did we observe more cleaning was needed here? Or is this just defensive?
created_at: 2025-03-28T19:33:40Z
author_association: MEMBER
diff_hunk: @@ -1170,6 +1171,16 @@ func (mgr *Manager) FirstRun(ctx context.Context, conf config.Module) error {
 	return conf.FirstRun(ctx, pkgsDir, dataDir, env, mgr.logger)
 }
 
+// On windows only, this mutates socket paths so they work well with the GRPC library.
+// If you don't do this, it will confuse grpc-go's url.Parse call and surrounding logic.
+// See https://github.com/grpc/grpc-go/blob/v1.71.0/clientconn.go#L1720-L1727
+func cleanWindowsSocketPath(orig string) string {
path: module/modmanager/manager.go
user: login: abe-winter
body: added
created_at: 2025-03-28T20:14:41Z
author_association: MEMBER
diff_hunk: @@ -1067,7 +1068,7 @@ func (m *module) dial() error {
 	var err error
 	addrToDial := m.addr
 	if !rutils.TCPRegex.MatchString(addrToDial) {
-		addrToDial = "unix://" + m.addr
+		addrToDial = "unix://" + cleanWindowsSocketPath(addrToDial)
path: module/modmanager/manager.go
user: login: abe-winter
body: wasn't thinking about the full history of that member

good catch, removed
created_at: 2025-03-28T20:15:17Z
author_association: MEMBER

PR comments:
user: login: abe-winter
created_at: 2025-03-28T20:16:56Z
body: note to reviewers -- acmorrow pointed out that this may not always be C drive, so I:
- added detection + error handling for non-C drive
- added a unit test
author_association: MEMBER
user: login: dgottlieb
created_at: 2025-03-28T20:27:27Z
body: > note to reviewers -- acmorrow pointed out that this may not always be C drive

Way better than my feedback, thanks @acmorrow !
author_association: MEMBER