Forge
csharp9332cf7c
1using System.Collections.Concurrent;
2using System.Threading.Channels;
3using HybridCodebaseIndex.Core;
4
5namespace HybridCodebaseIndex.Mcp;
6
7internal sealed class IndexWatchManager : IDisposable
8{
9 private sealed record WatchKey(string WorkspaceRoot, string? SolutionPath)
10 {
11 public override string ToString() => string.IsNullOrWhiteSpace(SolutionPath) ? WorkspaceRoot : $"{WorkspaceRoot} | {SolutionPath}";
12 }
13
14 private sealed class WatcherState : IDisposable
15 {
16 private readonly CodebaseIndexService _service;
17 private readonly string _workspaceRoot;
18 private readonly string? _solutionPath;
19 private readonly int _debounceMs;
20 private readonly Channel<int> _poke;
21 private readonly CancellationTokenSource _cts;
22 private readonly Task _loop;
23
24 private readonly FileSystemWatcher _fsw;
25
26 public WatcherState(CodebaseIndexService service, string workspaceRoot, string? solutionPath, int debounceMs)
27 {
28 _service = service;
29 _workspaceRoot = workspaceRoot;
30 _solutionPath = solutionPath;
31 _debounceMs = debounceMs;
32
33 _cts = new CancellationTokenSource();
34 _poke = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
35 {
36 SingleReader = true,
37 SingleWriter = false,
38 AllowSynchronousContinuations = false,
39 });
40
41 _fsw = new FileSystemWatcher(_workspaceRoot)
42 {
43 IncludeSubdirectories = true,
44 EnableRaisingEvents = true,
45 NotifyFilter = NotifyFilters.FileName
46 | NotifyFilters.DirectoryName
47 | NotifyFilters.LastWrite
48 | NotifyFilters.Size
49 | NotifyFilters.CreationTime,
50 };
51
52 _fsw.Changed += OnAny;
53 _fsw.Created += OnAny;
54 _fsw.Deleted += OnAny;
55 _fsw.Renamed += OnAny;
56 _fsw.Error += OnError;
57
58 _loop = Task.Run(LoopAsync, _cts.Token);
59 }
60
61 private void OnAny(object sender, FileSystemEventArgs e)
62 {
63 // Best-effort: ignore events from our own index directory.
64 // Avoid heavy path matching; a false-positive poke is ok because reindex is incremental.
65 _poke.Writer.TryWrite(0);
66 }
67
68 private void OnError(object sender, ErrorEventArgs e)
69 {
70 // FileSystemWatcher can drop events when buffer overflows; trigger a catch-up reindex.
71 _poke.Writer.TryWrite(0);
72 }
73
74 private async Task LoopAsync()
75 {
76 var ct = _cts.Token;
77 var nextDelay = Task.Delay(Timeout.Infinite, ct);
78 var hasPending = false;
79
80 while (!ct.IsCancellationRequested)
81 {
82 var read = _poke.Reader.ReadAsync(ct).AsTask();
83 var completed = await Task.WhenAny(read, nextDelay).ConfigureAwait(false);
84
85 if (completed == read)
86 {
87 // Drain bursts quickly; debounce handles the actual batching.
88 hasPending = true;
89 _ = await read.ConfigureAwait(false);
90 nextDelay = Task.Delay(_debounceMs, ct);
91 continue;
92 }
93
94 // Debounce window elapsed
95 if (!hasPending)
96 {
97 nextDelay = Task.Delay(Timeout.Infinite, ct);
98 continue;
99 }
100
101 hasPending = false;
102 nextDelay = Task.Delay(Timeout.Infinite, ct);
103
104 try
105 {
106 // Single-flight by key: serialize inside this watcher loop.
107 await _service.FullReindexAsync(_workspaceRoot, _solutionPath, ct).ConfigureAwait(false);
108 }
109 catch (OperationCanceledException) when (ct.IsCancellationRequested)
110 {
111 return;
112 }
113 catch
114 {
115 // Best-effort background task; status tool will surface last error.
116 }
117 }
118 }
119
120 public void Dispose()
121 {
122 _fsw.EnableRaisingEvents = false;
123 _fsw.Changed -= OnAny;
124 _fsw.Created -= OnAny;
125 _fsw.Deleted -= OnAny;
126 _fsw.Renamed -= OnAny;
127 _fsw.Error -= OnError;
128 _fsw.Dispose();
129
130 _cts.Cancel();
131 _poke.Writer.TryComplete();
132 try { _loop.GetAwaiter().GetResult(); } catch { /* ignore */ }
133 _cts.Dispose();
134 }
135 }
136
137 private readonly CodebaseIndexService _service;
138 private readonly ConcurrentDictionary<WatchKey, WatcherState> _watchers = new();
139
140 public IndexWatchManager(CodebaseIndexService service)
141 {
142 _service = service;
143 }
144
145 public void SetEnabled(string workspaceRoot, string? solutionPath, bool enabled, int debounceMs)
146 {
147 var root = Path.GetFullPath(workspaceRoot.TrimEnd(Path.DirectorySeparatorChar));
148 var key = new WatchKey(root, string.IsNullOrWhiteSpace(solutionPath) ? null : solutionPath.Trim());
149
150 if (!enabled)
151 {
152 if (_watchers.TryRemove(key, out var st))
153 st.Dispose();
154 return;
155 }
156
157 _watchers.AddOrUpdate(
158 key,
159 static (k, arg) => new WatcherState(arg.service, arg.root, arg.solutionPath, arg.debounceMs),
160 static (k, existing, arg) =>
161 {
162 // Recreate to apply debounce changes cleanly.
163 existing.Dispose();
164 return new WatcherState(arg.service, arg.root, arg.solutionPath, arg.debounceMs);
165 },
166 (service: _service, root, solutionPath: key.SolutionPath, debounceMs));
167 }
168
169 public void Dispose()
170 {
171 foreach (var kv in _watchers)
172 {
173 if (_watchers.TryRemove(kv.Key, out var st))
174 st.Dispose();
175 }
176 }
177}
178
179
View only · write via MCP/CIDE