Skip to content

Commit 7b1e192

Browse files
committed
Bound concurrent CLI metadata probes
1 parent 35f0921 commit 7b1e192

4 files changed

Lines changed: 242 additions & 36 deletions

File tree

‎CodexSharpSDK.Tests/Unit/BoundedCliProcessProbeTests.cs‎

Lines changed: 117 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,20 @@ public class BoundedCliProcessProbeTests
1313
private const string SentinelValue = "explicit-environment-value";
1414
private const string OverflowMessage = "CLI metadata process exceeded the configured output limit.";
1515
private const string TimeoutMessage = "CLI metadata process did not exit within the configured time limit.";
16-
private const string OutputTimeoutMessage = "CLI metadata process output did not close within the configured time limit.";
16+
private const string OutputCleanupFailedMessage = "CLI metadata process output readers did not stop within the configured time limit.";
17+
private const string ProbeBlockedMessage = "CLI metadata process probe is blocked by an earlier incomplete cleanup.";
18+
private const string ProbeBusyMessage = "CLI metadata process probe could not acquire its bounded process lease.";
1719
private const string LinuxFixtureSkipReason = "The detached inherited-pipe fixture requires Linux setsid.";
1820
private const string DetachedChildCommandPrefix = "/usr/bin/setsid /bin/sleep 30 >&2 & echo $! > '";
1921
private const string DetachedChildCommandSuffix = "'; exit 0";
2022
private const string FixtureDirectoryPrefix = "BoundedCliProcessProbe-";
21-
private const string ChildProcessIdFileName = "child.pid";
23+
private const string ChildProcessIdFilePattern = "child-*.pid";
2224
private const string StandardOutputPressureMarker = "stdout-pressure-1999";
2325
private const string StandardErrorPressureMarker = "stderr-pressure-1999";
2426
private const string StandardInputEofCommandUnix = "cat >/dev/null";
2527
private const string StandardInputEofCommandWindows = "more >NUL";
2628
private const int DetachedPipeProbeCount = 3;
29+
private static readonly TimeSpan ConcurrentProbeTimeout = TimeSpan.FromSeconds(3);
2730
private const string SentinelCommandUnix = "printf '%s' \"$SDK_METADATA_PROBE_SENTINEL\"";
2831
private const string SentinelCommandWindows = "echo %SDK_METADATA_PROBE_SENTINEL%";
2932
private const string PressureCommandUnix =
@@ -110,6 +113,55 @@ public async Task Run_StopsProcessWhenProbeTimesOut()
110113
await Assert.That(stopwatch.Elapsed).IsLessThan(ProcessTimeout);
111114
}
112115

116+
[Test]
117+
public async Task Run_RejectsConcurrentProcessBeforeStartingIt()
118+
{
119+
var firstReadersStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
120+
var firstReaderCount = 0;
121+
Action<int> firstReaderCountChanged = delta =>
122+
{
123+
if (Interlocked.Add(ref firstReaderCount, delta) == 2)
124+
{
125+
firstReadersStarted.TrySetResult();
126+
}
127+
};
128+
var firstProbe = Task.Run(() => Run(LongRunningCommandUnix, LongRunningCommandWindows,
129+
environment: null, inheritEnvironmentVariables: true, timeout: ConcurrentProbeTimeout,
130+
readerCountChanged: firstReaderCountChanged));
131+
132+
await firstReadersStarted.Task.WaitAsync(ProcessTimeout);
133+
134+
var secondReaderCount = 0;
135+
Action<int> secondReaderCountChanged = delta => Interlocked.Add(ref secondReaderCount, delta);
136+
var secondException = await Assert.That(() => BoundedCliProcessProbe.Run(
137+
OperatingSystem.IsWindows() ? Path.Combine(Environment.SystemDirectory, ShellExecutableWindows) : ShellExecutableUnix,
138+
[OperatingSystem.IsWindows() ? ShellArgumentWindows : ShellArgumentUnix,
139+
OperatingSystem.IsWindows() ? StandardInputEofCommandWindows : StandardInputEofCommandUnix],
140+
environment: null,
141+
inheritEnvironmentVariables: true,
142+
timeout: ProbeTimeout,
143+
maximumOutputCharacters: 64,
144+
readerCountChanged: secondReaderCountChanged,
145+
leaseAcquisitionTimeout: ProbeTimeout)).ThrowsException();
146+
147+
await Assert.That(secondException).IsTypeOf<InvalidOperationException>();
148+
await Assert.That(secondException!.Message).IsEqualTo(ProbeBusyMessage);
149+
await Assert.That(secondReaderCount).IsEqualTo(0);
150+
151+
Exception? firstException = null;
152+
try
153+
{
154+
await firstProbe;
155+
}
156+
catch (Exception exception)
157+
{
158+
firstException = exception;
159+
}
160+
161+
await Assert.That(firstException).IsTypeOf<TimeoutException>();
162+
await Assert.That(BoundedCliProcessProbe.PendingReaderCleanupCount).IsEqualTo(0);
163+
}
164+
113165
[Test]
114166
public async Task Run_FailsWithinBoundWhenExitedRootHasDescendantHoldingPipes()
115167
{
@@ -122,14 +174,22 @@ public async Task Run_FailsWithinBoundWhenExitedRootHasDescendantHoldingPipes()
122174
var sandbox = Path.Combine(Environment.CurrentDirectory, "tests", ".sandbox",
123175
$"{FixtureDirectoryPrefix}{Guid.NewGuid():N}");
124176
Directory.CreateDirectory(sandbox);
125-
var childProcessIdPath = Path.Combine(sandbox, ChildProcessIdFileName);
126-
var detachedCommand = string.Concat(DetachedChildCommandPrefix, childProcessIdPath,
127-
DetachedChildCommandSuffix);
128177
try
129178
{
130179
for (var attempt = 0; attempt < DetachedPipeProbeCount; attempt++)
131180
{
181+
var childProcessIdPath = Path.Combine(sandbox, $"child-{attempt}.pid");
182+
var detachedCommand = string.Concat(DetachedChildCommandPrefix, childProcessIdPath,
183+
DetachedChildCommandSuffix);
132184
var activeReaders = 0;
185+
var readersDrained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
186+
Action<int> readerCountChanged = delta =>
187+
{
188+
if (Interlocked.Add(ref activeReaders, delta) == 0)
189+
{
190+
readersDrained.TrySetResult();
191+
}
192+
};
133193
var stopwatch = Stopwatch.StartNew();
134194
var exception = await Assert.That(() => BoundedCliProcessProbe.Run(
135195
ShellExecutableUnix,
@@ -138,43 +198,77 @@ public async Task Run_FailsWithinBoundWhenExitedRootHasDescendantHoldingPipes()
138198
inheritEnvironmentVariables: true,
139199
timeout: ProbeTimeout,
140200
maximumOutputCharacters: 64,
141-
readerCountChanged: delta => Interlocked.Add(ref activeReaders, delta))).ThrowsException();
201+
readerCountChanged: readerCountChanged,
202+
leaseAcquisitionTimeout: ProcessTimeout)).ThrowsException();
142203
stopwatch.Stop();
143204

144-
await Assert.That(exception).IsTypeOf<TimeoutException>();
145-
await Assert.That(exception!.Message).IsEqualTo(OutputTimeoutMessage);
205+
await Assert.That(exception).IsTypeOf<InvalidOperationException>();
206+
await Assert.That(exception!.Message).IsEqualTo(OutputCleanupFailedMessage);
146207
await Assert.That(stopwatch.Elapsed).IsLessThan(ProcessTimeout);
208+
await Assert.That(activeReaders).IsEqualTo(2);
209+
await Assert.That(BoundedCliProcessProbe.PendingReaderCleanupCount).IsEqualTo(1);
210+
211+
var blockedChildPath = Path.Combine(sandbox, $"blocked-{attempt}.pid");
212+
var blockedCommand = string.Concat(DetachedChildCommandPrefix, blockedChildPath,
213+
DetachedChildCommandSuffix);
214+
var blockedException = await Assert.That(() => BoundedCliProcessProbe.Run(
215+
ShellExecutableUnix,
216+
[ShellArgumentUnix, blockedCommand],
217+
environment: null,
218+
inheritEnvironmentVariables: true,
219+
timeout: ProbeTimeout,
220+
maximumOutputCharacters: 64)).ThrowsException();
221+
222+
await Assert.That(blockedException).IsTypeOf<InvalidOperationException>();
223+
await Assert.That(blockedException!.Message).IsEqualTo(ProbeBlockedMessage);
224+
await Assert.That(File.Exists(blockedChildPath)).IsFalse();
225+
StopChildProcess(childProcessIdPath);
226+
await readersDrained.Task.WaitAsync(ProcessTimeout);
147227
await Assert.That(activeReaders).IsEqualTo(0);
228+
await Assert.That(BoundedCliProcessProbe.PendingReaderCleanupCount).IsEqualTo(0);
148229
}
149230
}
150231
finally
151232
{
152-
if (File.Exists(childProcessIdPath))
233+
foreach (var childProcessIdPath in Directory.EnumerateFiles(sandbox, ChildProcessIdFilePattern))
153234
{
154-
var processIdText = File.ReadAllText(childProcessIdPath);
155-
if (int.TryParse(processIdText, out var processId))
156-
{
157-
using var child = Process.GetProcessById(processId);
158-
if (!child.HasExited)
159-
{
160-
child.Kill(entireProcessTree: true);
161-
}
162-
163-
child.WaitForExit(1000);
164-
}
235+
StopChildProcess(childProcessIdPath);
165236
}
166237

167238
Directory.Delete(sandbox, recursive: true);
168239
}
169240
}
170241

242+
private static void StopChildProcess(string childProcessIdPath)
243+
{
244+
if (!File.Exists(childProcessIdPath))
245+
{
246+
return;
247+
}
248+
249+
var processIdText = File.ReadAllText(childProcessIdPath);
250+
if (!int.TryParse(processIdText, out var processId))
251+
{
252+
return;
253+
}
254+
255+
using var child = Process.GetProcessById(processId);
256+
if (!child.HasExited)
257+
{
258+
child.Kill(entireProcessTree: true);
259+
}
260+
261+
child.WaitForExit(1000);
262+
}
263+
171264
private static CliProcessProbeResult Run(
172265
string unixCommand,
173266
string windowsCommand,
174267
IReadOnlyDictionary<string, string>? environment,
175268
bool inheritEnvironmentVariables,
176269
TimeSpan? timeout = null,
177-
int maximumOutputCharacters = 200000)
270+
int maximumOutputCharacters = 200000,
271+
Action<int>? readerCountChanged = null)
178272
{
179273
var executablePath = OperatingSystem.IsWindows()
180274
? Path.Combine(Environment.SystemDirectory, ShellExecutableWindows)
@@ -185,6 +279,7 @@ private static CliProcessProbeResult Run(
185279
OperatingSystem.IsWindows() ? windowsCommand : unixCommand,
186280
};
187281
return BoundedCliProcessProbe.Run(executablePath, arguments, environment, inheritEnvironmentVariables,
188-
timeout ?? ProcessTimeout, maximumOutputCharacters);
282+
timeout ?? ProcessTimeout, maximumOutputCharacters, readerCountChanged,
283+
leaseAcquisitionTimeout: ProcessTimeout);
189284
}
190285
}

0 commit comments

Comments
 (0)