Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/AgentOs.RemoteAgent/AgentOs.RemoteAgent.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>AgentOs.RemoteAgent</RootNamespace>
<!-- ProcessJob.cs: source-generated P/Invoke passes the job limit struct by reference. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<ItemGroup>
Expand Down
13 changes: 11 additions & 2 deletions src/AgentOs.RemoteAgent/PreviewHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ private sealed class Running(string id, string repoKey, string dir)
public string RepoKey { get; } = repoKey;
public string Dir { get; } = dir;
public Process? Process { get; set; }
public ProcessJob Job { get; } = ProcessJob.Create();
public CancellationTokenSource Stop { get; } = new();
public Queue<string> Tail { get; } = new();
}
Expand Down Expand Up @@ -143,11 +144,19 @@ private async Task RunAsync(Running run, PreviewStart start)
return;
}

// Readiness is "something answers on the port" — so the port must be free before we start, or a leftover
// server (another checkout's dev server) would read as this app being up.
if (await IsListeningAsync(recipe.Url, ct).ConfigureAwait(false))
{
await FailAsync(run, $"Something else on this computer is already using port {recipe.Url.Port}. Close that app (or stop its preview), then try again.").ConfigureAwait(false);
return;
}
await report(run.Id, "Starting", $"Running {recipe.Command}").ConfigureAwait(false);
Console.WriteLine($"[agent] preview {run.RepoKey}: {recipe.Command} (in {run.Dir})");
var process = Process.Start(ShellFor(recipe.Command, run.Dir))
?? throw new InvalidOperationException("The command didn't start.");
run.Process = process;
run.Job.Add(process);
WritePidFile(run.Dir, process);
process.OutputDataReceived += (_, e) => Remember(run, e.Data);
process.ErrorDataReceived += (_, e) => Remember(run, e.Data);
Expand Down Expand Up @@ -178,6 +187,7 @@ private async Task RunAsync(Running run, PreviewStart start)
if (_byId.TryRemove(run.Id, out _))
{
_idByRepo.TryRemove(new KeyValuePair<string, string>(run.RepoKey, run.Id));
run.Job.Dispose(); // whatever it left running in the background goes with it
await report(run.Id, "Stopped", $"The app ended (exit {process.ExitCode}).\n{TailOf(run)}").ConfigureAwait(false);
}
}
Expand Down Expand Up @@ -314,8 +324,7 @@ private static void WritePidFile(string dir, Process process)

private static void Kill(Running run)
{
try { run.Process?.Kill(entireProcessTree: true); }
catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or NotSupportedException) { _ = ex; }
run.Job.Dispose(); // the app's whole tree, background children included
try { File.Delete(Path.Join(run.Dir, "preview.pid")); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { _ = ex; }
}
Expand Down
121 changes: 121 additions & 0 deletions src/AgentOs.RemoteAgent/ProcessJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Everything a run starts dies with the run. A CLI agent's background tools (a `vite` dev server, a test host, a corpus
// converter) outlived their run and held ports and memory for hours; when the runner itself was restarted, the whole
// CLI run carried on orphaned and raced its own retry for the same branch.
//
// On Windows the run goes into a Job Object with KILL_ON_JOB_CLOSE: children join the job automatically, and closing
// the handle — Dispose, or the OS when the runner process dies for any reason — kills every process still in it.
// Elsewhere it falls back to killing the root's current process tree on Dispose (no orphan guarantee after a hard kill).

using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace AgentOs.RemoteAgent;

/// <summary>A process tree that is killed as a whole when disposed (or when the runner exits, on Windows).</summary>
internal sealed partial class ProcessJob : IDisposable
{
private const int JobObjectExtendedLimitInformation = 9;
private const uint KillOnJobClose = 0x2000;

private nint _handle;
private readonly List<Process> _roots = [];

private ProcessJob(nint handle) => _handle = handle;

/// <summary>A new, empty job.</summary>
public static ProcessJob Create()
{
if (!OperatingSystem.IsWindows()) { return new ProcessJob(0); }
var handle = CreateJobObjectW(0, null);
if (handle == 0) { return new ProcessJob(0); } // best effort: still tracks roots
var info = new ExtendedLimitInformation { BasicLimitInformation = new BasicLimitInformation { LimitFlags = KillOnJobClose } };
if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, ref info, (uint)Marshal.SizeOf<ExtendedLimitInformation>()))
{
CloseHandle(handle);
return new ProcessJob(0);
}
return new ProcessJob(handle);
}

/// <summary>Put a just-started process (and everything it starts from now on) into the job.</summary>
public void Add(Process process)
{
ArgumentNullException.ThrowIfNull(process);
lock (_roots) { _roots.Add(process); }
if (_handle != 0)
{
try { _ = AssignProcessToJobObject(_handle, process.Handle); }
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) { _ = ex; } // exited already
}
}

/// <summary>Kill everything in the job.</summary>
public void Dispose()
{
List<Process> roots;
lock (_roots) { roots = [.. _roots]; _roots.Clear(); }
foreach (var root in roots)
{
try { if (!root.HasExited) { root.Kill(entireProcessTree: true); } }
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException) { _ = ex; }
}
var handle = Interlocked.Exchange(ref _handle, 0);
if (handle != 0) { CloseHandle(handle); } // KILL_ON_JOB_CLOSE: background children that left the tree go too
}

[StructLayout(LayoutKind.Sequential)]
private struct BasicLimitInformation
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public nuint MinimumWorkingSetSize;
public nuint MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public nuint Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}

[StructLayout(LayoutKind.Sequential)]
private struct IoCounters
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}

[StructLayout(LayoutKind.Sequential)]
private struct ExtendedLimitInformation
{
public BasicLimitInformation BasicLimitInformation;
public IoCounters IoInfo;
public nuint ProcessMemoryLimit;
public nuint JobMemoryLimit;
public nuint PeakProcessMemoryUsed;
public nuint PeakJobMemoryUsed;
}

[LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static partial nint CreateJobObjectW(nint attributes, string? name);

[LibraryImport("kernel32.dll", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool SetInformationJobObject(nint job, int infoClass, ref ExtendedLimitInformation info, uint length);

[LibraryImport("kernel32.dll", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool AssignProcessToJobObject(nint job, nint process);

[LibraryImport("kernel32.dll", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool CloseHandle(nint handle);
}
5 changes: 5 additions & 0 deletions src/AgentOs.RemoteAgent/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ static async Task<RemoteExecResult> RunLlmAsync(RemoteExecRequest request, Func<

using var process = Process.Start(psi)
?? throw new InvalidOperationException($"Failed to start '{cmd}'. Is the {cli} CLI installed and on PATH?");
// Whatever the run starts in the background (dev servers, test hosts) ends with it — see ProcessJob.cs.
using var job = AgentOs.RemoteAgent.ProcessJob.Create();
job.Add(process);
RunningCli[request.Id] = process;

if (profile.PromptViaStdin)
Expand Down Expand Up @@ -476,6 +479,8 @@ static async Task<RunnerToolResult> ExecuteToolAsync(

using var process = Process.Start(psi)
?? throw new InvalidOperationException($"Failed to start shell process.");
using var job = AgentOs.RemoteAgent.ProcessJob.Create();
job.Add(process);

var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
Expand Down
3 changes: 3 additions & 0 deletions tests/AgentOs.Tests/AgentOs.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

<PropertyGroup>
<RootNamespace>AgentOs.Tests</RootNamespace>
<!-- The linked runner ProcessJob.cs uses source-generated P/Invoke. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<AssemblyName>AgentOs.Tests</AssemblyName>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
Expand Down Expand Up @@ -53,6 +55,7 @@
<Compile Include="..\..\src\AgentOs.RemoteAgent\RunnerCredentials.cs" Link="Linked\RunnerCredentials.cs" />
<Compile Include="..\..\src\AgentOs.RemoteAgent\CliProgress.cs" Link="Linked\CliProgress.cs" />
<Compile Include="..\..\src\AgentOs.RemoteAgent\PreviewHost.cs" Link="Linked\PreviewHost.cs" />
<Compile Include="..\..\src\AgentOs.RemoteAgent\ProcessJob.cs" Link="Linked\ProcessJob.cs" />
</ItemGroup>

</Project>
61 changes: 61 additions & 0 deletions tests/AgentOs.Tests/Sessions/ProcessJobTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// A run's background children (a dev server it started and left behind) must die with the run — on the lab a vite
// server from a finished run held port 5173 and made "Try the app" report a stranger's server as ready.

using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using AgentOs.RemoteAgent;
using Shouldly;
using Xunit;

namespace AgentOs.Tests.Sessions;

public class ProcessJobTests
{
[Fact]
public async Task Dispose_KillsABackgroundChildThatOutlivedItsParent()
{
if (!OperatingSystem.IsWindows()) { return; } // the Job Object guarantee is Windows-only (see ProcessJob.cs)

var pidFile = Path.Join(Path.GetTempPath(), $"agentos-job-{Guid.NewGuid():N}.txt");
try
{
// The parent starts a detached child, writes its pid, and exits at once — the child is now an orphan.
var psi = new ProcessStartInfo("powershell.exe")
{
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-NoProfile");
psi.ArgumentList.Add("-Command");
psi.ArgumentList.Add($"$p = Start-Process ping -ArgumentList '-n','120','127.0.0.1' -PassThru -WindowStyle Hidden; Set-Content -Path '{pidFile}' -Value $p.Id");

using var job = ProcessJob.Create();
using (var parent = Process.Start(psi)!)
{
job.Add(parent);
await parent.WaitForExitAsync();
}
var childPid = int.Parse((await File.ReadAllTextAsync(pidFile)).Trim(), System.Globalization.CultureInfo.InvariantCulture);
using var child = Process.GetProcessById(childPid);
child.HasExited.ShouldBeFalse();

job.Dispose();

(await Task.Run(() => child.WaitForExit(TimeSpan.FromSeconds(10)))).ShouldBeTrue();
}
finally
{
File.Delete(pidFile);
}
}

[Fact]
public void Dispose_Twice_IsHarmless()
{
var job = ProcessJob.Create();
job.Dispose();
Should.NotThrow(job.Dispose);
}
}
Loading