Skip to content

Commit 384583a

Browse files
Merge pull request #9 from raythurman2386/bugfix/move-race-condition
Fix: Race conditions in move operations and improve installation
2 parents e967f3f + c6baa84 commit 384583a

File tree

6 files changed

+192
-15
lines changed

6 files changed

+192
-15
lines changed

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ build:
1313
$(GO) build -o bin/strawd ./cmd/strawd
1414

1515
install:
16+
@if pgrep -x strawd > /dev/null 2>&1; then \
17+
echo "Stopping existing strawd instances..."; \
18+
pkill -x strawd 2>/dev/null || true; \
19+
sleep 1; \
20+
fi
1621
install -d $(DESTDIR)$(BINDIR)
1722
install -m 755 bin/straw $(DESTDIR)$(BINDIR)/
1823
install -m 755 bin/strawd $(DESTDIR)$(BINDIR)/

install.sh

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@ if [ ! -f "straw" ] || [ ! -f "strawd" ]; then
110110
exit 1
111111
fi
112112

113+
# Stop any running strawd instances before installing new binary
114+
echo "Checking for running strawd instances..."
115+
if pgrep -x strawd > /dev/null 2>&1; then
116+
echo "Stopping existing strawd processes..."
117+
pkill -x strawd 2>/dev/null || true
118+
sleep 1
119+
fi
120+
113121
# Install binaries
114122
if [ -w "$INSTALL_DIR" ]; then
115123
echo "Installing to $INSTALL_DIR..."
@@ -136,6 +144,13 @@ if [ "$OS" = "linux" ] && command -v systemctl >/dev/null 2>&1; then
136144
SERVICE_DIR="${HOME}/.config/systemd/user"
137145
mkdir -p "$SERVICE_DIR"
138146

147+
# Stop existing service before updating binary
148+
if systemctl --user is-active --quiet strawd.service 2>/dev/null; then
149+
echo "Stopping existing strawd service..."
150+
systemctl --user stop strawd.service
151+
sleep 1
152+
fi
153+
139154
cat > "$SERVICE_DIR/strawd.service" << EOF
140155
[Unit]
141156
Description=Straw File Automation Daemon
@@ -153,9 +168,15 @@ EOF
153168

154169
systemctl --user daemon-reload
155170
systemctl --user enable strawd.service
156-
systemctl --user start strawd.service
157171

158-
echo "Started strawd service"
172+
# Start or restart the service
173+
if systemctl --user is-active --quiet strawd.service 2>/dev/null; then
174+
systemctl --user restart strawd.service
175+
echo "Restarted strawd service"
176+
else
177+
systemctl --user start strawd.service
178+
echo "Started strawd service"
179+
fi
159180
fi
160181

161182
echo ""

internal/actions/actions.go

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,27 +43,65 @@ func (e *Executor) move(src, destDir string) error {
4343
return err
4444
}
4545

46+
srcInfo, err := os.Stat(src)
47+
if err != nil {
48+
if os.IsNotExist(err) {
49+
// Source is already gone — a prior event already moved it.
50+
slog.Debug("Source file already removed, skipping move", "src", src)
51+
return nil
52+
}
53+
return err
54+
}
55+
4656
destPath := e.getDestPath(src, destDir)
4757

48-
// Ensure we aren't moving a file into itself or sensitive areas if we can help it
4958
if destPath == src {
5059
return fmt.Errorf("source and destination are the same: %s", src)
5160
}
5261

53-
// Attempt standard rename
54-
err := os.Rename(src, destPath)
55-
if err == nil {
62+
// Directories can only be renamed, not copied as a fallback.
63+
if srcInfo.IsDir() {
64+
return e.renameOrSkip(src, destPath)
65+
}
66+
67+
// Attempt standard rename for files.
68+
renameErr := e.renameOrSkip(src, destPath)
69+
if renameErr == nil {
5670
return nil
5771
}
5872

59-
// If rename fails (e.g. cross-device), fallback to copy + delete
60-
slog.Debug("Standard rename failed, attempting copy+delete fallback", "src", src, "dest", destPath, "error", err)
73+
// If rename fails (e.g. cross-device), fallback to copy + delete.
74+
slog.Debug("Standard rename failed, attempting copy+delete fallback", "src", src, "dest", destPath, "renameError", renameErr)
6175

6276
if err := e.copyToPath(src, destPath); err != nil {
77+
slog.Debug("copyToPath failed", "src", src, "dest", destPath, "error", err, "isNotExist", os.IsNotExist(err))
78+
if os.IsNotExist(err) {
79+
slog.Debug("Source file disappeared during fallback copy", "src", src)
80+
return nil
81+
}
6382
return fmt.Errorf("fallback copy failed: %w", err)
6483
}
6584

66-
return os.Remove(src)
85+
// Clean up the source after a successful copy.
86+
if err := os.Remove(src); err != nil && !os.IsNotExist(err) {
87+
return fmt.Errorf("failed to remove source after copy: %w", err)
88+
}
89+
90+
return nil
91+
}
92+
93+
// renameOrSkip attempts os.Rename. If the source has already been removed
94+
// (e.g. by a concurrent event), it returns nil instead of an error.
95+
func (e *Executor) renameOrSkip(src, dest string) error {
96+
err := os.Rename(src, dest)
97+
if err == nil {
98+
return nil
99+
}
100+
if os.IsNotExist(err) {
101+
slog.Debug("Source file disappeared before rename completed", "src", src, "dest", dest)
102+
return nil
103+
}
104+
return err
67105
}
68106

69107
func (e *Executor) copy(src, destDir string) error {

internal/actions/actions_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,63 @@ func TestExecutor_MoveCrossDevice(t *testing.T) {
9090
}
9191
}
9292

93+
// TestExecutor_MoveSourceAlreadyMoved tests that calling move on a file
94+
// that was already moved to the destination (e.g. by a duplicate event)
95+
// succeeds without returning an error.
96+
func TestExecutor_MoveSourceAlreadyMoved(t *testing.T) {
97+
e := NewExecutor()
98+
tmpDir, err := os.MkdirTemp("", "move_already_test")
99+
if err != nil {
100+
t.Fatal(err)
101+
}
102+
defer os.RemoveAll(tmpDir)
103+
104+
src := filepath.Join(tmpDir, "src.txt")
105+
if err := os.WriteFile(src, []byte("hello"), 0644); err != nil {
106+
t.Fatal(err)
107+
}
108+
109+
destDir := filepath.Join(tmpDir, "out")
110+
111+
// First move should succeed normally
112+
err = e.move(src, destDir)
113+
if err != nil {
114+
t.Fatalf("first move failed: %v", err)
115+
}
116+
117+
if _, err := os.Stat(filepath.Join(destDir, "src.txt")); os.IsNotExist(err) {
118+
t.Fatal("file should exist in destination after first move")
119+
}
120+
121+
// Second move on the same (now missing) source path should not error,
122+
// because the file is already at the destination.
123+
err = e.move(src, destDir)
124+
if err != nil {
125+
t.Errorf("second move should succeed (file already at destination), got: %v", err)
126+
}
127+
}
128+
129+
// TestExecutor_MoveSourceGone tests that calling move on a source file
130+
// that no longer exists is treated as a no-op success, since a prior
131+
// event already handled it.
132+
func TestExecutor_MoveSourceGone(t *testing.T) {
133+
e := NewExecutor()
134+
tmpDir, err := os.MkdirTemp("", "move_gone_test")
135+
if err != nil {
136+
t.Fatal(err)
137+
}
138+
defer os.RemoveAll(tmpDir)
139+
140+
src := filepath.Join(tmpDir, "ghost.txt")
141+
destDir := filepath.Join(tmpDir, "out")
142+
143+
// Source never existed — move should treat this as a no-op (already handled)
144+
err = e.move(src, destDir)
145+
if err != nil {
146+
t.Errorf("move should succeed when source does not exist (already handled), got: %v", err)
147+
}
148+
}
149+
93150
func TestExecutor_Shell(t *testing.T) {
94151
e := NewExecutor()
95152
tmpDir, err := os.MkdirTemp("", "shell_test")

internal/watcher/watcher.go

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import (
55
"path/filepath"
66
"strings"
77
"sync"
8+
"time"
89

910
"github.com/fsnotify/fsnotify"
1011
)
1112

13+
const debounceInterval = 100 * time.Millisecond
14+
1215
type EventType int
1316

1417
const (
@@ -89,18 +92,32 @@ func (w *Watcher) Start() {
8992
}
9093

9194
func (w *Watcher) loop() {
95+
// pending tracks debounced events keyed by file path.
96+
// When multiple fsnotify events arrive for the same path within
97+
// debounceInterval, only the last event is forwarded.
98+
pending := make(map[string]Event)
99+
timers := make(map[string]*time.Timer)
100+
101+
// flush receives paths whose debounce timer has expired,
102+
// keeping all map access on the loop goroutine.
103+
flush := make(chan string, 100)
104+
92105
for {
93106
select {
94107
case event, ok := <-w.fsWatcher.Events:
95108
if !ok {
109+
// Flush remaining pending events before exiting
110+
for path, ev := range pending {
111+
w.events <- ev
112+
delete(pending, path)
113+
}
96114
return
97115
}
98116

99-
// Handle new directories
117+
// Handle new directories immediately (not debounced)
100118
if event.Op&fsnotify.Create == fsnotify.Create {
101119
info, err := os.Stat(event.Name)
102120
if err == nil && info.IsDir() {
103-
// Check if inside a recursive root
104121
if w.isRecursive(event.Name) {
105122
if err := w.Add(event.Name, true); err != nil {
106123
w.errors <- err
@@ -109,16 +126,39 @@ func (w *Watcher) loop() {
109126
}
110127
}
111128

112-
// Forward event
113-
// We might want to debounce or filter here, but for now just raw events.
114-
w.events <- translateEvent(event)
129+
// Debounce: coalesce multiple events for the same path.
130+
// Each new event resets the timer; only when the timer
131+
// expires (no new events for debounceInterval) do we
132+
// forward the final event to consumers.
133+
translated := translateEvent(event)
134+
pending[event.Name] = translated
135+
136+
if t, exists := timers[event.Name]; exists {
137+
t.Reset(debounceInterval)
138+
} else {
139+
path := event.Name
140+
timers[path] = time.AfterFunc(debounceInterval, func() {
141+
flush <- path
142+
})
143+
}
144+
145+
case path := <-flush:
146+
if ev, ok := pending[path]; ok {
147+
w.events <- ev
148+
delete(pending, path)
149+
delete(timers, path)
150+
}
115151

116152
case err, ok := <-w.fsWatcher.Errors:
117153
if !ok {
118154
return
119155
}
120156
w.errors <- err
121157
case <-w.done:
158+
// Stop all pending timers on shutdown
159+
for _, t := range timers {
160+
t.Stop()
161+
}
122162
return
123163
}
124164
}

scripts/install_service.sh

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,25 @@ WantedBy=default.target
5050
EOF
5151

5252
echo "Installing service..."
53+
54+
# Stop existing service before updating
55+
if systemctl --user is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
56+
echo "Stopping existing strawd service..."
57+
systemctl --user stop "$SERVICE_NAME"
58+
sleep 1
59+
fi
60+
5361
systemctl --user daemon-reload
5462
systemctl --user enable "$SERVICE_NAME"
55-
systemctl --user restart "$SERVICE_NAME"
63+
64+
# Start or restart the service
65+
if systemctl --user is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
66+
systemctl --user restart "$SERVICE_NAME"
67+
echo "Restarted strawd service"
68+
else
69+
systemctl --user start "$SERVICE_NAME"
70+
echo "Started strawd service"
71+
fi
5672

5773
echo "---------------------------------------------------"
5874
echo "✅ Straw daemon is now running as a user service!"

0 commit comments

Comments
 (0)