Skip to content

Instantly share code, notes, and snippets.

@davidfowl
Last active June 3, 2025 06:29
Show Gist options
  • Save davidfowl/ce64c9e37bd2d3a8b5da1e3d77988d2b to your computer and use it in GitHub Desktop.
Save davidfowl/ce64c9e37bd2d3a8b5da1e3d77988d2b to your computer and use it in GitHub Desktop.
using System.Diagnostics;
using System.Reflection;
using System.Threading.Channels;
using Aspire.Hosting.Eventing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
public static class ProjectResourceBuilderExtensions
{
public static IResourceBuilder<ProjectResource> RestartOnChange(this IResourceBuilder<ProjectResource> builder, params string[] filePatterns)
{
if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder;
}
var projectDirectory = Path.GetDirectoryName(builder.Resource.GetProjectMetadata().ProjectPath)!;
var watchers = new List<FileSystemWatcher>();
// Create a watcher for each pattern
foreach (var pattern in filePatterns)
{
var watcher = new FileSystemWatcher(projectDirectory)
{
Filter = pattern,
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
EnableRaisingEvents = true
};
watchers.Add(watcher);
}
var channel = Channel.CreateBounded<FileSystemEventArgs>(new BoundedChannelOptions(1)
{
SingleReader = true,
FullMode = BoundedChannelFullMode.DropOldest
});
var gate = new SemaphoreSlim(1, 1);
foreach (var watcher in watchers)
{
void DebouncedWrite(FileSystemEventArgs args)
{
// Only allow one event through the gate at a time
if (gate.Wait(0))
{
try
{
channel.Writer.TryWrite(args);
}
finally
{
// Do not release here; release after processing in the fileChangeTask
}
}
// else: gate is held, so ignore this event
}
watcher.Changed += (s, args) => DebouncedWrite(args);
watcher.Created += (s, args) => DebouncedWrite(args);
watcher.Deleted += (s, args) => DebouncedWrite(args);
watcher.Renamed += (s, args) => DebouncedWrite(args);
}
// Root the watchers to the resource
builder.WithAnnotation(new FileWatcherAnnotation(watchers));
// Hidden resource used for build orchestration
var build = builder.ApplicationBuilder.AddExecutable($"build-{builder.Resource.Name}", "dotnet", projectDirectory)
.WithArgs("build")
.WithParentRelationship(builder.Resource)
.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "Executable",
Properties = [],
// IsHidden = true (debugging)
});
builder.WaitForCompletion(build);
var eventing = builder.ApplicationBuilder.Eventing;
eventing.Subscribe<ResourceRestartingEvent>(builder.Resource, async (e, ct) =>
{
var logger = e.Services.GetRequiredService<ILoggerFactory>().CreateLogger("FileWatcherResourceExtension");
var rns = e.Services.GetRequiredService<ResourceNotificationService>();
var resolvedResourceName = GetResolvedResourceNames(build.Resource)[0];
var startCommand = build.Resource.Annotations.OfType<ResourceCommandAnnotation>().FirstOrDefault(c => c.Name == "resource-start") ??
throw new InvalidOperationException("No start command found.");
try
{
await startCommand.ExecuteCommand(new ExecuteCommandContext()
{
CancellationToken = ct,
ResourceName = resolvedResourceName,
ServiceProvider = e.Services
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to restart resource after file change");
try
{
Process.GetProcessById(e.Pid).Kill();
}
catch (Exception killEx)
{
logger.LogError(killEx, "Failed to kill process after file change");
}
}
});
var cts = new CancellationTokenSource();
eventing.Subscribe<ResourceReadyEvent>(builder.Resource, async (e, ct) =>
{
var logger = e.Services.GetRequiredService<ILoggerFactory>().CreateLogger("FileWatcherResourceExtension");
logger.LogInformation("File watcher started for resource: {ResourceName} in {ProjectDirectory}", builder.Resource.Name, projectDirectory);
var rns = e.Services.GetRequiredService<ResourceNotificationService>();
// Wait until we have the pid of the resource
logger.LogInformation("Waiting to resolve the process ID (PID) of the resource...");
int pid = 0;
await foreach (var @event in rns.WatchAsync())
{
if (@event.Resource == builder.Resource)
{
var prop = @event.Snapshot.Properties.SingleOrDefault(p => p.Name == "executable.pid");
pid = int.Parse(prop?.Value?.ToString() ?? throw new InvalidOperationException("PID not found in resource properties"));
if (pid > 0)
{
break;
}
}
}
logger.LogInformation("Resource {ResourceName} is healthy with PID {Pid}", builder.Resource.Name, pid);
try
{
var resourceLogger = e.Services.GetRequiredService<ResourceLoggerService>().GetLogger(builder.Resource);
var startCommand = e.Resource.Annotations.OfType<ResourceCommandAnnotation>().FirstOrDefault(c => c.Name == "resource-start") ??
throw new InvalidOperationException("No start command found.");
var stopCommand = e.Resource.Annotations.OfType<ResourceCommandAnnotation>().FirstOrDefault(c => c.Name == "resource-stop") ??
throw new InvalidOperationException("No stop command found.");
var resolvedResourceName = GetResolvedResourceNames(builder.Resource)[0];
cts.Cancel();
cts = new CancellationTokenSource();
var context = new ExecuteCommandContext()
{
CancellationToken = cts.Token,
ResourceName = resolvedResourceName,
ServiceProvider = e.Services
};
var fileChangeTask = Task.Run(async () =>
{
try
{
logger.LogInformation("Watching for file changes...");
while (await channel.Reader.WaitToReadAsync(cts.Token))
{
if (channel.Reader.TryRead(out var args))
{
resourceLogger.LogInformation("File change detected: {FileName}", args.FullPath);
logger.LogInformation("Restarting resource due to file change...{FileName}", args.FullPath);
try
{
await stopCommand.ExecuteCommand(context);
await eventing.PublishAsync(new ResourceRestartingEvent(e.Services, e.Resource, pid));
await startCommand.ExecuteCommand(context);
}
catch (Exception ex)
{
resourceLogger.LogError(ex, "Failed to restart resource after file change");
logger.LogError(ex, "Failed to restart resource after file change: {FileName}", args.FullPath);
}
finally
{
gate.Release(); // Allow the next event through
}
// Break out of the watching task - the resource will restart and set up a new watcher
return;
}
}
}
catch (OperationCanceledException)
{
// The task was cancelled due to a restart, which is expected
resourceLogger.LogInformation("File change watcher will restart after resource restart");
if (gate.CurrentCount == 0)
{
gate.Release();
}
}
}, cts.Token);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to set up file change watcher");
}
});
return builder;
}
// Aspire.Hosting.ApplicationModel.ResourceExtensions.GetResolvedResourceNames
public static string[] GetResolvedResourceNames(IResource resource)
{
var method = typeof(ResourceExtensions).GetMethod("GetResolvedResourceNames", BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("GetResolvedResourceNames method not found");
return method.Invoke(null, [resource]) as string[] ?? throw new InvalidOperationException("GetResolvedResourceNames returned null");
}
private class FileWatcherAnnotation(List<FileSystemWatcher> watches) : IResourceAnnotation
{
public List<FileSystemWatcher> Watches { get; } = watches;
public void Dispose()
{
foreach (var watcher in Watches)
{
watcher.Dispose();
}
}
}
private class ResourceRestartingEvent(IServiceProvider services, IResource resource, int pid) : IDistributedApplicationResourceEvent
{
public IServiceProvider Services { get; } = services;
public IResource Resource => resource;
public int Pid => pid;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment