Harden core snapshot sync and workspace lifecycle
This commit is contained in:
@@ -35,11 +35,23 @@ type Service struct {
|
||||
bus *events.Bus
|
||||
interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
root string
|
||||
cancel chan struct{}
|
||||
done chan struct{}
|
||||
current map[string]snapshotEntry
|
||||
mu sync.Mutex
|
||||
root string
|
||||
cancel chan struct{}
|
||||
done chan struct{}
|
||||
current map[string]snapshotEntry
|
||||
onChange func()
|
||||
}
|
||||
|
||||
// SetOnChange installs a lightweight notification used by core services that
|
||||
// need a debounced reconciliation after the watcher has observed a change.
|
||||
func (s *Service) SetOnChange(callback func()) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.onChange = callback
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// NewService creates a watcher. The interval parameter is mainly for tests.
|
||||
@@ -133,22 +145,30 @@ func (s *Service) poll(root string) {
|
||||
s.mu.Lock()
|
||||
prev := s.current
|
||||
s.current = next
|
||||
callback := s.onChange
|
||||
s.mu.Unlock()
|
||||
changed := false
|
||||
for path, entry := range next {
|
||||
old, ok := prev[path]
|
||||
if !ok {
|
||||
s.publish(path, "external.create", entry.kind)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if entry.kind == entryFile && (entry.size != old.size || !entry.modTime.Equal(old.modTime)) {
|
||||
s.publish(path, "external.update", entry.kind)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for path, entry := range prev {
|
||||
if _, ok := next[path]; !ok {
|
||||
s.publish(path, "external.delete", entry.kind)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed && callback != nil {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publish(path, operation string, kind entryKind) {
|
||||
@@ -217,8 +237,12 @@ func kindFromInfo(info fs.FileInfo) entryKind {
|
||||
}
|
||||
|
||||
func isReserved(rel string) bool {
|
||||
first := strings.Split(filepath.ToSlash(rel), "/")[0]
|
||||
return strings.EqualFold(first, ".verstak")
|
||||
for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
|
||||
if strings.EqualFold(segment, ".verstak") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func workspaceRoot(path string) string {
|
||||
|
||||
@@ -74,6 +74,26 @@ func TestServiceIgnoresReservedVerstakPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCallsChangeCallbackForExternalChanges(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
service := NewService(events.NewBus(), 10*time.Millisecond)
|
||||
changed := make(chan struct{}, 1)
|
||||
service.SetOnChange(func() { changed <- struct{}{} })
|
||||
if err := service.Start(root); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(service.Stop)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "external.txt"), []byte("change"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-changed:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for watcher callback")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForEvent(t *testing.T, eventCh <-chan events.Event) events.Event {
|
||||
t.Helper()
|
||||
select {
|
||||
|
||||
Reference in New Issue
Block a user