Last active
June 3, 2025 06:29
-
-
Save davidfowl/ce64c9e37bd2d3a8b5da1e3d77988d2b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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