diff options
| author | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-07 11:49:16 +0200 |
|---|---|---|
| committer | Bernhard Guillon <Bernhard.Guillon@begu.org> | 2026-07-07 11:49:16 +0200 |
| commit | 540d7476fb2a3ec812720158f797a3a916eadeba (patch) | |
| tree | c246f315ff39391a25a8201a5847d7fa349ab543 | |
| parent | c7fc7f7cdfc533b492b13b9a57689441878d1fee (diff) | |
| download | sourcewatch-540d7476fb2a3ec812720158f797a3a916eadeba.tar.gz sourcewatch-540d7476fb2a3ec812720158f797a3a916eadeba.zip | |
Switch from Unix sockets to TCP for Docker Desktop compatibility
Architecture:
- Container acts as TCP server (listens on :5151)
- Windows binary connects as TCP client
- Same protocol: newline-delimited JSON
Usage:
docker run -p 5151:5151 -v C:\src:/host:ro -v data:/data sourcewatch
sourcewatch.exe -dir C:\src -connect localhost:5151
This avoids Docker Desktop's inability to mount Windows Unix sockets
into Linux containers (they become directories instead of files).
| -rw-r--r-- | README.md | 57 | ||||
| -rw-r--r-- | cmd_linux.go | 55 | ||||
| -rw-r--r-- | cmd_windows.go | 105 |
3 files changed, 101 insertions, 116 deletions
@@ -7,17 +7,18 @@ A file synchronization tool that watches a Windows source directory and syncs ch ``` Windows Host Docker Container ┌─────────────────────┐ ┌─────────────────────────┐ -│ sourcewatch.exe │ │ sourcewatch (linux) │ -│ watches C:\src │ AF_UNIX │ - receives manifest │ -│ ReadDirectory │◄────────────►│ - syncs changed files │ -│ ChangesW │ socket │ - listens for events │ +│ sourcewatch.exe │ TCP │ sourcewatch (linux) │ +│ watches C:\src │◄────────────►│ - receives manifest │ +│ ReadDirectory │ connect │ - syncs changed files │ +│ ChangesW │ :5151 │ - listens for events │ └─────────────────────┘ └─────────────────────────┘ ``` -1. **Windows binary** scans source directory (fast native NTFS) and sends file metadata -2. **Container** compares with local state, copies only changed files -3. **Windows binary** watches for changes, sends events over socket -4. **Container** receives events and syncs in real-time +1. **Container** starts and listens on TCP port 5151 +2. **Windows binary** connects to container, scans source directory (fast native NTFS), sends file metadata +3. **Container** compares with local state, copies only changed files +4. **Windows binary** watches for changes, sends events over TCP +5. **Container** receives events and syncs in real-time ## Building @@ -51,21 +52,10 @@ podman build -t sourcewatch . GOOS=windows GOARCH=amd64 go build -o sourcewatch.exe . ``` -### Step 2: Start the Windows watcher +### Step 2: Start the container ```bash -# PowerShell -.\sourcewatch.exe -dir C:\myproject -socket D:\sourcewatch.sock - -# Git Bash -./sourcewatch.exe -dir /c/myproject -socket /d/sourcewatch.sock -``` - -### Step 3: Start the container - -```bash -docker run -d \ - -v D:\sourcewatch.sock:/run/sourcewatch.sock \ +docker run -d -p 5151:5151 \ -v C:\myproject:/host:ro \ -v project-data:/data \ sourcewatch @@ -74,19 +64,28 @@ docker run -d \ Or with Podman: ```bash -podman run -d \ - -v D:\sourcewatch.sock:/run/sourcewatch.sock \ +podman run -d -p 5151:5151 \ -v C:\myproject:/host:ro \ -v project-data:/data \ sourcewatch ``` +### Step 3: Start the Windows watcher + +```powershell +# PowerShell +.\sourcewatch.exe -dir C:\myproject -connect localhost:5151 + +# Git Bash +./sourcewatch.exe -dir /c/myproject -connect localhost:5151 +``` + ### Standalone mode (no Windows watcher) -If you don't need real-time watching, run without `-socket`: +If you don't need real-time watching, run with `-listen ""`: ```bash -docker run -v /path/to/source:/host:ro -v data:/data sourcewatch +docker run -v /path/to/source:/host:ro -v data:/data sourcewatch -listen "" ``` ## Flags @@ -95,7 +94,7 @@ docker run -v /path/to/source:/host:ro -v data:/data sourcewatch ``` -dir string Source directory to watch (required) --socket string Unix socket path to serve events on (required) +-connect string TCP address of the container (default "localhost:5151") -log-level string Log level: debug, info, warn, error, none (default "info") -verbose Enable verbose logging (shorthand for -log-level debug) ``` @@ -105,7 +104,7 @@ docker run -v /path/to/source:/host:ro -v data:/data sourcewatch ``` -host string Host source directory (default "/host") -data string Data destination directory (default "/data") --socket string Unix socket to receive events from (default "/run/sourcewatch.sock") +-listen string TCP address to listen on (default ":5151") -verbose Enable verbose logging (shorthand for -log-level debug) -log-level string Log level: debug, info, warn, error, none (default "info") -dry-run Preview what would be synced without copying @@ -113,7 +112,7 @@ docker run -v /path/to/source:/host:ro -v data:/data sourcewatch ## Protocol -Communication uses newline-delimited JSON over Unix socket: +Communication uses newline-delimited JSON over TCP: ```json {"type":"manifest","files":{"path":{"size":1234,"modtime":"2024-01-01T00:00:00Z","mode":"0644"}}} @@ -142,7 +141,7 @@ build/ - One-way sync only (`/host` → `/data`) - `/host` must be read-only - `?` single-character wildcard not supported (go-gitignore limitation) -- Windows binary requires Go 1.24+ to build +- Windows binary requires Go 1.21+ to build ## License diff --git a/cmd_linux.go b/cmd_linux.go index 65e8644..069881a 100644 --- a/cmd_linux.go +++ b/cmd_linux.go @@ -15,10 +15,10 @@ import ( ) var ( - hostDir = flag.String("host", "/host", "Host source directory (read-only)") - dataDir = flag.String("data", "/data", "Data destination directory") - dryRun = flag.Bool("dry-run", false, "Preview what would be synced without copying") - socketPath = flag.String("socket", "/run/sourcewatch.sock", "Unix socket to receive events from (Windows watcher)") + hostDir = flag.String("host", "/host", "Host source directory (read-only)") + dataDir = flag.String("data", "/data", "Data destination directory") + dryRun = flag.Bool("dry-run", false, "Preview what would be synced without copying") + listen = flag.String("listen", ":5151", "TCP address to listen on for Windows watcher connections") ) func run() { @@ -40,8 +40,8 @@ func run() { stack := NewGitignoreStack(*hostDir) - if *socketPath == "" { - logInfo("no -socket specified, running standalone (full scan)") + if *listen == "" { + logInfo("no -listen specified, running standalone (full scan)") manifest, err := initialSync(*hostDir, *dataDir, stack, *dryRun) if err != nil { logErrorf("initial sync failed: %v", err) @@ -57,29 +57,35 @@ func run() { return } - logInfo("watcher: connecting to %s ...", *socketPath) + ln, err := net.Listen("tcp", *listen) + if err != nil { + logErrorf("listen: %v", err) + os.Exit(1) + } + defer ln.Close() + + logInfo("watcher: listening on %s", *listen) + logInfo("watcher: waiting for Windows watcher to connect ...") - var conn net.Conn for { if ctx.Err() != nil { - logErrorf("cancelled while connecting") - os.Exit(1) - } - conn, err = net.Dial("unix", *socketPath) - if err == nil { - break + return } - logDebug("connect failed: %v, retrying...", err) - select { - case <-ctx.Done(): - logErrorf("cancelled while connecting") - os.Exit(1) - case <-time.After(1 * time.Second): + + conn, err := ln.Accept() + if err != nil { + logErrorf("accept: %v", err) + continue } + + logInfo("watcher: client connected from %s", conn.RemoteAddr()) + handleClient(conn, stack) + logInfo("watcher: client disconnected, waiting for reconnect ...") } - defer conn.Close() +} - logInfo("watcher: connected, receiving manifest ...") +func handleClient(conn net.Conn, stack *GitignoreStack) { + defer conn.Close() scanner := bufio.NewScanner(conn) remoteManifest := &Manifest{Files: make(map[string]FileMeta)} @@ -134,7 +140,7 @@ func run() { } if err := scanner.Err(); err != nil { - logErrorf("socket read: %v", err) + logErrorf("read: %v", err) } } @@ -142,7 +148,6 @@ func syncFromManifest(remote *Manifest, stack *GitignoreStack) { stats := &syncStats{} start := time.Now() - // Scan local /data to get current state (fast, local volume) localManifest := scanLocalDirectory(*dataDir) for relPath, remoteMeta := range remote.Files { @@ -157,7 +162,6 @@ func syncFromManifest(remote *Manifest, stack *GitignoreStack) { destPath := filepath.Join(*dataDir, relPath) srcPath := filepath.Join(*hostDir, relPath) - // Check if file already exists locally with same metadata if localMeta, exists := localManifest.Files[relPath]; exists { if localMeta.Size == remoteMeta.Size && localMeta.Mode == remoteMeta.Mode && @@ -167,7 +171,6 @@ func syncFromManifest(remote *Manifest, stack *GitignoreStack) { } } - // Need to sync info, err := os.Lstat(srcPath) if err != nil { if os.IsNotExist(err) { diff --git a/cmd_windows.go b/cmd_windows.go index 61c7f1d..7df0a9b 100644 --- a/cmd_windows.go +++ b/cmd_windows.go @@ -19,15 +19,15 @@ import ( ) var ( - socketPath = flag.String("socket", "", "Unix socket path to serve events on (required)") - sourceDir = flag.String("dir", "", "Source directory to watch (required)") + sourceDir = flag.String("dir", "", "Source directory to watch (required)") + connect = flag.String("connect", "localhost:5151", "TCP address of the container to connect to") ) func run() { flag.Parse() - if *socketPath == "" || *sourceDir == "" { - fmt.Fprintln(os.Stderr, "Usage: sourcewatch.exe -dir <source> -socket <socket-path>") + if *sourceDir == "" { + fmt.Fprintln(os.Stderr, "Usage: sourcewatch.exe -dir <source> [-connect host:port]") os.Exit(1) } @@ -43,7 +43,7 @@ func run() { stack := NewGitignoreStack(*sourceDir) manifest := scanDirectory(*sourceDir, stack) - logInfo("watcher: found %d files, serving on %s", len(manifest.Files), *socketPath) + logInfo("watcher: found %d files", len(manifest.Files)) watcher, err := fsnotify.NewWatcher() if err != nil { @@ -57,75 +57,61 @@ func run() { os.Exit(1) } - os.Remove(*socketPath) - - ln, err := net.Listen("unix", *socketPath) - if err != nil { - logErrorf("socket: %v", err) - os.Exit(1) - } - defer func() { - ln.Close() - os.Remove(*socketPath) - }() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() - var mu sync.Mutex - debounceTimers := make(map[string]*time.Timer) - debounceOps := make(map[string]fsnotify.Op) + for { + if ctx.Err() != nil { + return + } - clients := make(map[net.Conn]bool) - var clientsMu sync.Mutex + logInfo("watcher: connecting to %s ...", *connect) - go func() { + var conn net.Conn for { - conn, err := ln.Accept() - if err != nil { + if ctx.Err() != nil { + return + } + conn, err = net.DialTimeout("tcp", *connect, 5*time.Second) + if err == nil { + break + } + logDebug("connect failed: %v, retrying...", err) + select { + case <-ctx.Done(): return + case <-time.After(2 * time.Second): } - clientsMu.Lock() - clients[conn] = true - clientsMu.Unlock() - logDebug("client connected: %s", conn.RemoteAddr()) + } - go func(c net.Conn) { - sendManifest(c, manifest) + logInfo("watcher: connected, sending manifest ...") - scanner := bufio.NewScanner(c) - for scanner.Scan() { - } + sendManifest(conn, manifest) - clientsMu.Lock() - delete(clients, c) - clientsMu.Unlock() - c.Close() - }(conn) - } - }() + logInfo("watcher: monitoring for changes ...") - broadcast := func(evt FileEvent) { - clientsMu.Lock() - defer clientsMu.Unlock() - for c := range clients { - sendEvent(c, evt) + if !watchLoop(ctx, conn, watcher, manifest, stack) { + conn.Close() + continue } + conn.Close() + return } +} - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer cancel() - - go func() { - <-ctx.Done() - ln.Close() - }() +func watchLoop(ctx context.Context, conn net.Conn, watcher *fsnotify.Watcher, manifest *Manifest, stack *GitignoreStack) bool { + var mu sync.Mutex + debounceTimers := make(map[string]*time.Timer) + debounceOps := make(map[string]fsnotify.Op) for { select { case <-ctx.Done(): - return + return true case event, ok := <-watcher.Events: if !ok { - return + return true } relPath, err := filepath.Rel(*sourceDir, event.Name) @@ -192,7 +178,6 @@ func run() { return } - // Update manifest srcPath := filepath.Join(*sourceDir, path) switch evt.Op { case "create": @@ -225,15 +210,16 @@ func run() { delete(manifest.Files, path) } - broadcast(evt) + sendEvent(conn, evt) }) mu.Unlock() case err, ok := <-watcher.Errors: if !ok { - return + return true } logErrorf("watcher: %v", err) + return false } } } @@ -307,10 +293,7 @@ func sendManifest(conn net.Conn, manifest *Manifest) { } func sendEvent(conn net.Conn, evt FileEvent) { - msg := &ProtocolMessage{ - Type: "event", - Path: evt.Path, - } + msg := &ProtocolMessage{Path: evt.Path} switch evt.Op { case "create": msg.Type = "event_create" |
