Harden core snapshot sync and workspace lifecycle

This commit is contained in:
2026-07-17 04:10:59 +08:00
parent ba0ba5f8c4
commit e3d8078ad5
22 changed files with 2891 additions and 132 deletions
+31 -7
View File
@@ -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 {
+20
View File
@@ -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 {