@@ -13,9 +13,21 @@ public class ProcessFilterTests
1313 private static ProcessFilter Filter (
1414 IEnumerable < int > ? pids = null ,
1515 IEnumerable < string > ? names = null ,
16+ bool watchProcessTree = false ,
1617 Func < int , int ? > ? resolvePid = null ,
17- Func < int , string ? > ? resolveName = null ) =>
18- new ( pids ?? [ ] , names ?? [ ] , resolvePid , resolveName ) ;
18+ Func < int , string ? > ? resolveName = null ,
19+ Func < int , DateTimeOffset ? > ? resolveStartTime = null ,
20+ Func < IReadOnlyDictionary < int , int > > ? resolveParentPids = null ,
21+ Func < DateTimeOffset > ? utcNow = null ) =>
22+ new (
23+ pids ?? [ ] ,
24+ names ?? [ ] ,
25+ watchProcessTree ,
26+ resolvePid ,
27+ resolveName ,
28+ resolveStartTime ,
29+ resolveParentPids ,
30+ utcNow ) ;
1931
2032 [ Fact ]
2133 public void IsEmpty_True_WhenNoFilterConfigured ( )
@@ -103,6 +115,218 @@ public void IsWatchedProcess_PidWins_WithoutResolvingName()
103115 resolveName : _ => throw new InvalidOperationException ( "name lookup not needed" ) ) ;
104116 Assert . True ( filter . IsWatchedProcess ( 54321 ) ) ;
105117 }
118+
119+ [ Fact ]
120+ public void IsWatchedProcess_False_ForChild_WhenProcessTreeDisabled ( )
121+ {
122+ var filter = Filter (
123+ pids : [ 100 ] ,
124+ resolvePid : _ => 300 ,
125+ resolveParentPids : ( ) => new Dictionary < int , int > { [ 300 ] = 200 , [ 200 ] = 100 } ) ;
126+
127+ Assert . False ( filter . IsWatchedProcess ( 54321 ) ) ;
128+ }
129+
130+ [ Fact ]
131+ public void IsWatchedProcess_True_WhenAncestorPidMatches ( )
132+ {
133+ var filter = Filter (
134+ pids : [ 100 ] ,
135+ watchProcessTree : true ,
136+ resolvePid : _ => 300 ,
137+ resolveName : _ => null ,
138+ resolveStartTime : pid => DateTimeOffset . UnixEpoch . AddSeconds ( pid ) ,
139+ resolveParentPids : ( ) => new Dictionary < int , int > { [ 300 ] = 200 , [ 200 ] = 100 } ) ;
140+
141+ Assert . True ( filter . IsWatchedProcess ( 54321 ) ) ;
142+ }
143+
144+ [ Fact ]
145+ public void IsWatchedProcess_True_WhenAncestorNameMatches ( )
146+ {
147+ var names = new Dictionary < int , string >
148+ {
149+ [ 100 ] = "Visual Studio Code" ,
150+ [ 200 ] = "extension-host" ,
151+ [ 300 ] = "node"
152+ } ;
153+ var filter = Filter (
154+ names : [ "Visual Studio Code" ] ,
155+ watchProcessTree : true ,
156+ resolvePid : _ => 300 ,
157+ resolveName : pid => names [ pid ] ,
158+ resolveStartTime : pid => DateTimeOffset . UnixEpoch . AddSeconds ( pid ) ,
159+ resolveParentPids : ( ) => new Dictionary < int , int > { [ 300 ] = 200 , [ 200 ] = 100 } ) ;
160+
161+ Assert . True ( filter . IsWatchedProcess ( 54321 ) ) ;
162+ }
163+
164+ [ Fact ]
165+ public void IsWatchedProcess_False_WhenNoAncestorMatches ( )
166+ {
167+ var filter = Filter (
168+ names : [ "Visual Studio Code" ] ,
169+ watchProcessTree : true ,
170+ resolvePid : _ => 300 ,
171+ resolveName : pid => pid == 100 ? "terminal" : "node" ,
172+ resolveStartTime : pid => DateTimeOffset . UnixEpoch . AddSeconds ( pid ) ,
173+ resolveParentPids : ( ) => new Dictionary < int , int > { [ 300 ] = 200 , [ 200 ] = 100 } ) ;
174+
175+ Assert . False ( filter . IsWatchedProcess ( 54321 ) ) ;
176+ }
177+
178+ [ Fact ]
179+ public void IsWatchedProcess_UsesCachedDecision_ForSameProcessInstance ( )
180+ {
181+ var parentResolutionCount = 0 ;
182+ var filter = Filter (
183+ pids : [ 100 ] ,
184+ watchProcessTree : true ,
185+ resolvePid : _ => 300 ,
186+ resolveName : _ => null ,
187+ resolveStartTime : pid => DateTimeOffset . UnixEpoch . AddSeconds ( pid ) ,
188+ resolveParentPids : ( ) =>
189+ {
190+ parentResolutionCount ++ ;
191+ return new Dictionary < int , int > { [ 300 ] = 200 , [ 200 ] = 100 } ;
192+ } ) ;
193+
194+ Assert . True ( filter . IsWatchedProcess ( 54321 ) ) ;
195+ Assert . True ( filter . IsWatchedProcess ( 54322 ) ) ;
196+ Assert . Equal ( 1 , parentResolutionCount ) ;
197+ }
198+
199+ [ Fact ]
200+ public void IsWatchedProcess_DoesNotReuseCache_WhenPidIsReused ( )
201+ {
202+ var startTime = DateTimeOffset . UnixEpoch ;
203+ var parents = new Dictionary < int , int > { [ 300 ] = 100 } ;
204+ var filter = Filter (
205+ pids : [ 100 ] ,
206+ watchProcessTree : true ,
207+ resolvePid : _ => 300 ,
208+ resolveName : _ => null ,
209+ resolveStartTime : _ => startTime ,
210+ resolveParentPids : ( ) => parents ) ;
211+
212+ Assert . True ( filter . IsWatchedProcess ( 54321 ) ) ;
213+
214+ startTime = startTime . AddMinutes ( 1 ) ;
215+ parents = new Dictionary < int , int > { [ 300 ] = 400 } ;
216+
217+ Assert . False ( filter . IsWatchedProcess ( 54322 ) ) ;
218+ }
219+
220+ [ Fact ]
221+ public void IsWatchedProcess_False_WhenAncestorPidWasReused ( )
222+ {
223+ var processStartTimes = new Dictionary < int , DateTimeOffset >
224+ {
225+ [ 300 ] = DateTimeOffset . UnixEpoch . AddMinutes ( 1 ) ,
226+ // PID 100 is listed as the creator of 300, but the process currently
227+ // owning that PID started later and is therefore unrelated.
228+ [ 100 ] = DateTimeOffset . UnixEpoch . AddMinutes ( 2 )
229+ } ;
230+ var filter = Filter (
231+ names : [ "Visual Studio Code" ] ,
232+ watchProcessTree : true ,
233+ resolvePid : _ => 300 ,
234+ resolveName : pid => pid == 100 ? "Visual Studio Code" : "node" ,
235+ resolveStartTime : pid => processStartTimes [ pid ] ,
236+ resolveParentPids : ( ) => new Dictionary < int , int > { [ 300 ] = 100 } ) ;
237+
238+ Assert . False ( filter . IsWatchedProcess ( 54321 ) ) ;
239+ }
240+
241+ [ Fact ]
242+ public void IsWatchedProcess_False_WhenAncestorStartTimeCannotBeResolved ( )
243+ {
244+ var filter = Filter (
245+ pids : [ 100 ] ,
246+ watchProcessTree : true ,
247+ resolvePid : _ => 300 ,
248+ resolveName : _ => null ,
249+ resolveStartTime : pid => pid == 300 ? DateTimeOffset . UnixEpoch : null ,
250+ resolveParentPids : ( ) => new Dictionary < int , int > { [ 300 ] = 100 } ) ;
251+
252+ Assert . False ( filter . IsWatchedProcess ( 54321 ) ) ;
253+ }
254+
255+ [ Fact ]
256+ public void IsWatchedProcess_RefreshesExpiredCacheEntry ( )
257+ {
258+ var now = DateTimeOffset . UnixEpoch ;
259+ var parentResolutionCount = 0 ;
260+ var filter = Filter (
261+ pids : [ 100 ] ,
262+ watchProcessTree : true ,
263+ resolvePid : _ => 300 ,
264+ resolveName : _ => null ,
265+ resolveStartTime : pid => DateTimeOffset . UnixEpoch . AddSeconds ( pid ) ,
266+ resolveParentPids : ( ) =>
267+ {
268+ parentResolutionCount ++ ;
269+ return new Dictionary < int , int > { [ 300 ] = 100 } ;
270+ } ,
271+ utcNow : ( ) => now ) ;
272+
273+ Assert . True ( filter . IsWatchedProcess ( 54321 ) ) ;
274+
275+ now = now . AddMinutes ( 2 ) ;
276+
277+ Assert . True ( filter . IsWatchedProcess ( 54322 ) ) ;
278+ Assert . Equal ( 2 , parentResolutionCount ) ;
279+ }
280+
281+ [ Fact ]
282+ public void IsWatchedProcess_StopsWhenParentGraphContainsCycle ( )
283+ {
284+ var filter = Filter (
285+ pids : [ 100 ] ,
286+ watchProcessTree : true ,
287+ resolvePid : _ => 300 ,
288+ resolveName : _ => null ,
289+ resolveStartTime : pid => DateTimeOffset . UnixEpoch . AddSeconds ( pid ) ,
290+ resolveParentPids : ( ) => new Dictionary < int , int > { [ 300 ] = 200 , [ 200 ] = 300 } ) ;
291+
292+ Assert . False ( filter . IsWatchedProcess ( 54321 ) ) ;
293+ }
294+ }
295+
296+ public class ProcessTreeResolverTests
297+ {
298+ [ Fact ]
299+ public void ParsePsOutput_ReturnsParentRelationships ( )
300+ {
301+ const string output = """
302+ 1 0
303+ 100 1
304+ 200 100
305+ 300 200
306+ """ ;
307+
308+ var parents = ProcessTreeResolver . ParsePsOutput ( output ) ;
309+
310+ Assert . Equal ( 0 , parents [ 1 ] ) ;
311+ Assert . Equal ( 1 , parents [ 100 ] ) ;
312+ Assert . Equal ( 100 , parents [ 200 ] ) ;
313+ Assert . Equal ( 200 , parents [ 300 ] ) ;
314+ }
315+
316+ [ Fact ]
317+ public void ParsePsOutput_IgnoresMalformedLines ( )
318+ {
319+ const string output = """
320+ PID PPID
321+ invalid
322+ 200 100
323+ """ ;
324+
325+ var parents = ProcessTreeResolver . ParsePsOutput ( output ) ;
326+
327+ Assert . Single ( parents ) ;
328+ Assert . Equal ( 100 , parents [ 200 ] ) ;
329+ }
106330}
107331
108332public class LsofParserTests
0 commit comments