If we want to monitor a Windows Forms application by using the heartbeat signal, we need to make sure that the heartbeat signal is sent from the UI thread. Otherwise, the heartbeat signal may seem fine but the Winfows Forms application has already frozen and not responding.
In order to make sure the heartbeat signal is sent from UI thread, we need to use the same SynchronizationContext object from the UI thread.
SynchronizationContext
SynchronizationContext provides a way to queue a unit of work to a context. Every thread has a “current” context. A thread’s context isn’t necessarily unique; its context instance may be shared with other threads.
Windows Forms apps will create a WindowsFormsSynchronizationContext as its current context. All delegates queued to the WindowsFormsSynchronizationContext are executed one at a time; they’re executed by a specific UI thread in the order they were queued.
Post() Method
public virtual void Post (System.Threading.SendOrPostCallback d, object? state);
The Post method starts an asynchronous request to post a message. The SendOrPostCallbak definition is:
public delegate void SendOrPostCallback(object? state);
You can pass a parameter to the call back.
Send() Method
public virtual void Send (System.Threading.SendOrPostCallback d, object? state);
The only difference between Send() and Post() is Send() is Send() is synchronous operation while Post() is asynchronous.
Static Current Property
The static property “Current” gets the synchronization context for the current thread.
public static System.Threading.SynchronizationContext? Current { get; }
Generating Heartbeat
Heartbeat Generator Service
This service is implemented as a BackgrounService. It sends out a heartbeat signal on a configured interval using the SynchronizationContext provided (UI thread context). The heartbeat can be paused and resumed. After sending each heartbeat signal, it will call the HeartbeatSent event handler.
using Heartbeat.Abstractions;
using Microsoft.Extensions.Hosting;
namespace HeartbeatClientService
{
public class HeartbeatGeneratorService(IHeartbeatClientChannel heartbeatChannel) : BackgroundService, IHeartbeatGeneratorService
{
private readonly IHeartbeatClientChannel _heartbeatChannel = heartbeatChannel;
public event EventHandler<HeartbeatStatus> HeartbeatSent;
private bool _isPaused;
private bool _isPauseSignalSent;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (HeartbeatContext != null)
{
if (!_isPaused)
{
HeartbeatContext.Post(async (_) => {
await _heartbeatChannel.SendAsync("*", stoppingToken);
}, null);
OnHeartBeat(HeartbeatStatus.Beating);
}
else if (!_isPauseSignalSent)
{
HeartbeatContext.Post(async (_) => {
await _heartbeatChannel.SendAsync("-", stoppingToken);
}, null);
_isPauseSignalSent = true;
OnHeartBeat(HeartbeatStatus.Pause);
}
}
await Task.Delay(_heartbeatChannel.HeartbeatInterval * 1000, stoppingToken);
}
}
public SynchronizationContext? HeartbeatContext { get; set; }
public void Pause()
{
_isPaused = true;
}
public void Resume()
{
_isPaused = false;
_isPauseSignalSent = false;
}
private void OnHeartBeat(HeartbeatStatus status)
{
if (HeartbeatSent != null)
{
HeartbeatSent(this, status);
}
}
}
}
Monitoring Heartbeat
Heartbeat Monitoring Service
This service is implemented as a BackgrounService. It listens on the heartbeat channel for heartbeat signals. Once received, it will call the HeartbeatReceived event handler. The monitoring UI app can subscribe this event to display heartbeat signal received message.
using Heartbeat.Abstractions;
using Microsoft.Extensions.Hosting;
namespace HeartbeatServerService
{
public class HeartbeatMonitorService(IHeartbeatMonitorChannel heartbeatChannel) : BackgroundService, IHeartbeatMonitorService
{
private readonly IHeartbeatMonitorChannel _heartbeatChannel = heartbeatChannel;
public event EventHandler<HeartbeatStatus> HeartbeatReceived;
private bool _isPaused;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
string? data = await _heartbeatChannel.ReceiveAsync();
if (data == null)
{
OnHeartBeat(HeartbeatStatus.Gone);
break;
}
else if (data.Equals("*"))
{
_isPaused = false;
OnHeartBeat(HeartbeatStatus.Beating);
}
else if (data.Equals("-"))
{
_isPaused = true;
OnHeartBeat(HeartbeatStatus.Pause);
}
else if (!_isPaused)
{
OnHeartBeat(HeartbeatStatus.Missing);
}
}
}
private void OnHeartBeat(HeartbeatStatus status)
{
if (HeartbeatReceived != null)
{
HeartbeatReceived(this, status);
}
}
}
}
Heartbeat Channel
Heartbeat can be sent out through different channels. In this article, we will use the AnonymousPipeClientStream and AnonymousPipeServerStream as the communication channels.
Anonymous pipes are unnamed, one-way pipes that typically used to transfer data between parent and child processes within the same server.
The server side anonymous pipe should be created first, then the server side code should call the GetClientHandleAsString() method to get a pipe handle. This pipe handle then would be passed to client application. The client application would pass this pipe handle to AnonymousPipeClientStream constructor to create client side anonymous pipe.
After the pipe handle passed to the client application, the AnonymousPipeServerStream object must dispose the client handle using the DisposeLocalCopyOfClientHandle() method in order to be notified when the client exits.
Hearbeat client channel:
using Heartbeat.Abstractions;
using System.IO.Pipes;
using System.Threading;
namespace HeartbeatChannel.Client
{
public class AnonymousPipeClientChannel : IHeartbeatClientChannel, IDisposable
{
private bool _disposed;
private PipeStream _pipeStream;
private StreamWriter _pipeWriter;
private int _heartbeatInterval;
public AnonymousPipeClientChannel(string pipeHandle, int heartbeatInterval)
{
_pipeStream = new AnonymousPipeClientStream(PipeDirection.Out, pipeHandle);
_pipeWriter = new StreamWriter(_pipeStream);
_pipeWriter.AutoFlush = true;
_heartbeatInterval = heartbeatInterval;
}
public int HeartbeatInterval => _heartbeatInterval;
public async Task SendAsync(string data, CancellationToken cancellationToken)
{
await _pipeWriter.WriteLineAsync(data);
}
public void Dispose()
{
// Dispose of unmanaged resources.
Dispose(true);
// Suppress finalization.
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
if (_pipeStream != null)
{
_pipeStream.Dispose();
}
if (_pipeWriter != null)
{
_pipeWriter.Dispose();
}
}
_disposed = true;
}
}
}
Hearbeat server channel:
using Heartbeat.Abstractions;
using System.IO.Pipes;
namespace HeartBeatService.Server
{
public class AnonymousPipeServerChannel : IHeartbeatMonitorChannel, IDisposable
{
private bool _disposed;
private AnonymousPipeServerStream _pipeStream;
private StreamReader _pipeReader;
private int _heartbeatInterval;
public object ChannelHandle
{
get
{
string handle = _pipeStream.GetClientHandleAsString();
return handle;
}
}
public void DisposeLocalCopyChannelHandle()
{
_pipeStream?.DisposeLocalCopyOfClientHandle();
}
public AnonymousPipeServerChannel(int heartbeatInterval)
{
_pipeStream = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
_pipeReader = new StreamReader(_pipeStream);
_heartbeatInterval = heartbeatInterval;
}
public async Task<string?> ReceiveAsync()
{
var timeoutTokenSource = new CancellationTokenSource();
timeoutTokenSource.CancelAfter((_heartbeatInterval + 1) * 1000);
try
{
return await _pipeReader.ReadLineAsync(timeoutTokenSource.Token);
}
catch
{
return ".";
}
}
public void Dispose()
{
// Dispose of unmanaged resources.
Dispose(true);
// Suppress finalization.
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
if (_pipeStream != null)
{
_pipeStream.Dispose();
}
if (_pipeReader != null)
{
_pipeReader.Dispose();
}
}
_disposed = true;
}
}
}
Windows Forms App with Heartbeat
In order for a Windows Forms App to send the hearbeat signal, it would need to create a GenericHost and register the Heartbeat Generator Service to it as a hosted service.
The client app takes two command line parameters:
- Pipe handle: this is the pipe handle created on the server side. It will be used to create AnonymousPipeClientStream.
- Heartbeat interval: This is the interval (in seconds) that the heartbeat should be sent.
Program.cs
using Heartbeat.Abstractions;
using HeartbeatChannel.Client;
using HeartbeatClientService;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace MainForm
{
internal static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main(string[] args)
{
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
if (args.Length != 2)
{
throw new ArgumentException("We need two parameters (anonymous pipe handle and heartbeat interval) to run the app.");
}
var host = CreateHostBuilder(args[0], int.Parse(args[1])).Build();
host.RunAsync();
Application.Run(host.Services.GetRequiredService<Form1>());
}
static IHostBuilder CreateHostBuilder(string pipeHandler, int heartbeatInterval)
{
return Host.CreateDefaultBuilder()
.ConfigureServices((context, services) => {
services.AddSingleton<IHeartbeatClientChannel, AnonymousPipeClientChannel>((services) => new AnonymousPipeClientChannel(pipeHandler, heartbeatInterval));
services.AddSingleton<HeartbeatGeneratorService>();
services.AddHostedService(sp => sp.GetRequiredService<HeartbeatGeneratorService>());
services.AddTransient<Form1>();
});
}
}
}
Main Form:
The UI looks like following:
It has button the Pause and Resume the heartbeat. It also has a button to block the UI thread for 10 seconds so we can see during this 10 seconds, there is no heartbeat signal sent.
using Heartbeat.Abstractions;
using HeartbeatClientService;
using Microsoft.Extensions.DependencyInjection;
namespace MainForm
{
public partial class Form1 : Form
{
private IHeartbeatGeneratorService _heartbeatClientService;
public Form1(IServiceProvider provider)
{
InitializeComponent();
_heartbeatClientService = provider.GetRequiredService<HeartbeatGeneratorService>();
_heartbeatClientService.HeartbeatSent += UpdateHeartbeatStatus;
_heartbeatClientService.HeartbeatContext = SynchronizationContext.Current;
}
private void Form1_Load(object sender, EventArgs e)
{
btnPause.Enabled = true;
btnResume.Enabled = false;
}
public void UpdateHeartbeatStatus(object? sender, HeartbeatStatus status)
{
switch (status)
{
case HeartbeatStatus.Beating:
txtOutput.Invoke(() => txtOutput.AppendText($"Heartbeat sent at {DateTime.Now.ToString("HH:mm:ss")}\r\n"));
break;
case HeartbeatStatus.Pause:
txtOutput.Invoke(() => txtOutput.AppendText($"Heartbeat paused at {DateTime.Now.ToString("HH:mm:ss")}\r\n"));
break;
}
}
private void Form1_Close(object sender, EventArgs e)
{
}
private void btnPause_Click(object sender, EventArgs e)
{
_heartbeatClientService.Pause();
btnPause.Enabled = false;
btnResume.Enabled = true;
}
private void btnResume_Click(object sender, EventArgs e)
{
_heartbeatClientService.Resume();
btnPause.Enabled = true;
btnResume.Enabled = false;
}
private void btnBlock_Click(object sender, EventArgs e)
{
Thread.Sleep(10000);
}
}
}
Windows Forms App that Monitoring Heartbeat
The monitoring Windows Forms App would create a GenericHost and register the Heartbeat Monitor Service to it as a hosted service. It then launches the Windows Forms app that generate heartbeat as a child process and pass the pipe handle and heartbeat interval as the command line arguments.
Programe.cs
using Heartbeat.Abstractions;
using HeartbeatChannel.Client;
using HeartbeatServerService;
using HeartBeatService.Server;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace MonitoringApp
{
internal static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
var host = CreateHostBuilder().Build();
host.RunAsync();
Application.Run(host.Services.GetRequiredService<MonitorForm>());
}
static IHostBuilder CreateHostBuilder()
{
int _heartbeatInterval = 5;
return Host.CreateDefaultBuilder()
.ConfigureAppConfiguration((context, configBuilder) => {
var configuration = configBuilder.AddJsonFile("appsettings.json").Build();
if (!int.TryParse(configuration["heartbeat:intervalInSeconds"], out _heartbeatInterval))
{
_heartbeatInterval = 5;
}
})
.ConfigureServices((context, services) => {
services.AddSingleton<IHeartbeatMonitorChannel, AnonymousPipeServerChannel>((services) => new AnonymousPipeServerChannel(_heartbeatInterval));
services.AddSingleton<HeartbeatMonitorService>();
services.AddHostedService(sp => sp.GetRequiredService<HeartbeatMonitorService>());
services.AddTransient((sp) => new MonitorForm(sp, _heartbeatInterval));
});
}
}
}
Monitoring Form
The UI looks like following:
Code
using Heartbeat.Abstractions;
using HeartbeatServerService;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;
namespace MonitoringApp
{
public partial class MonitorForm : Form
{
private IServiceProvider _provider;
private IHeartbeatMonitorService _heartbeatServerService;
private Process clientApp = new Process();
private int _heartbeatInterval;
public MonitorForm(IServiceProvider provider, int heartbeatInterval)
{
InitializeComponent();
_heartbeatServerService = provider.GetRequiredService<HeartbeatMonitorService>();
_heartbeatServerService.HeartbeatReceived += UpdateHeartbeatStatus;
_provider = provider;
_heartbeatInterval = heartbeatInterval;
}
public void UpdateHeartbeatStatus(object? sender, HeartbeatStatus status)
{
switch (status)
{
case HeartbeatStatus.Beating:
txtOutput.Invoke(() => txtOutput.AppendText($"Heartbeat is received at {DateTime.Now.ToString("HH:mm:ss")}\r\n"));
break;
case HeartbeatStatus.Missing:
txtOutput.Invoke(() => txtOutput.AppendText($"Heartbeat is missed at {DateTime.Now.ToString("HH:mm:ss")}\r\n"));
break;
case HeartbeatStatus.Pause:
txtOutput.Invoke(() => txtOutput.AppendText($"Heartbeat paused at {DateTime.Now.ToString("HH:mm:ss")}\r\n"));
break;
case HeartbeatStatus.Gone:
txtOutput.Invoke(() => txtOutput.AppendText($"Heartbeat is gone at {DateTime.Now.ToString("HH:mm:ss")}\r\n"));
break;
}
}
private void Form1_Load(object sender, EventArgs e)
{
var channel = _provider.GetRequiredService<IHeartbeatMonitorChannel>();
clientApp.StartInfo.FileName = "..\\..\\..\\..\\MainForm\\bin\\Debug\\net8.0-windows\\MainForm.exe";
clientApp.StartInfo.Arguments = channel.ChannelHandle.ToString() + " " + _heartbeatInterval.ToString();
clientApp.StartInfo.UseShellExecute = false;
clientApp.Start();
channel.DisposeLocalCopyChannelHandle();
}
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
clientApp.Kill();
}
}
}
You can find the complete source code at github:
jason-ge/WinFomrsAppHeartbeatDemo (github.com)
Happy Coding!