Skip to content

Commit 6286c80

Browse files
authored
Support watching child process traffic
Support watching child process traffic
2 parents e13fbd3 + 41ded7e commit 6286c80

10 files changed

Lines changed: 558 additions & 18 deletions

File tree

‎DevProxy.Abstractions/Proxy/IProxyConfiguration.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,9 @@ public interface IProxyConfiguration
5050
bool ValidateSchemas { get; }
5151
IEnumerable<int> WatchPids { get; set; }
5252
IEnumerable<string> WatchProcessNames { get; set; }
53+
}
54+
55+
public interface IProcessTreeProxyConfiguration
56+
{
57+
bool WatchProcessTree { get; set; }
5358
}

‎DevProxy.Integration.Tests/TestProxyConfiguration.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ namespace DevProxy.Integration.Tests;
1111
/// <summary>
1212
/// Minimal <see cref="IProxyConfiguration"/> for the integration harness. Only the
1313
/// members the Kestrel engine reads at boot (Port, IPAddress, AsSystemProxy,
14-
/// WatchPids/WatchProcessNames) actually matter; the rest carry inert defaults.
14+
/// WatchPids/WatchProcessNames/WatchProcessTree) actually matter; the rest carry inert defaults.
1515
/// </summary>
16-
internal sealed class TestProxyConfiguration : IProxyConfiguration
16+
internal sealed class TestProxyConfiguration : IProxyConfiguration, IProcessTreeProxyConfiguration
1717
{
1818
public string ApiIpAddress { get; set; } = "127.0.0.1";
1919
public int ApiPort { get; set; }
@@ -35,4 +35,5 @@ internal sealed class TestProxyConfiguration : IProxyConfiguration
3535
public bool ValidateSchemas => false;
3636
public IEnumerable<int> WatchPids { get; set; } = [];
3737
public IEnumerable<string> WatchProcessNames { get; set; } = [];
38+
public bool WatchProcessTree { get; set; }
3839
}

‎DevProxy.Proxy.Kestrel.Tests/ProcessFilterTests.cs‎

Lines changed: 226 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

108332
public class LsofParserTests

0 commit comments

Comments
 (0)