Porting WinMTR to Avalonia. A Fifteen-Year-Old MFC Tool on Windows, macOS and Linux
WinMTR combines traceroute and ping in a single window. The original app’s last release is dated 31 January 2011. This is the write-up of porting it from MFC to Avalonia and .NET 10, keeping the tool and discarding the platform. The result, winmtr-remaster, is my entry in the Avalonia Port Challenge.
A recurring theme of articles about network tooling on Windows is that the platform never caught up with the Unix side. That statement is mostly true, and WinMTR is the counter-example that shows the shape of the problem. It is genuinely useful, and it has not shipped a release in fifteen years.
The tool it implements is MTR, Matt’s Traceroute. It sends probes with an increasing TTL and keeps pinging every hop it discovers. Therefore, a single window tells you which hop on the path loses packets, instead of a traceroute telling you the path and a separate ping telling you that one endpoint is fine. When a customer says “the site is slow from the office”, this is the first thing worth opening.
The network algorithm is still useful, but the code around it depends on Windows. WinMTRNet.cpp calls IcmpSendEcho through GetProcAddress on ICMP.DLL, the dialogs use MFC, and the settings live in HKCU\Software\WinMTR. Together, these choices tie the original app to Windows.
That matters because the machines I diagnose from are no longer all Windows.
So the question this port answers is narrow: what has to change in a fifteen-year-old MFC application for the same tool to run on Windows, macOS and Linux, and what should be allowed to change while we are in there anyway?
Reading the Original
Initially, I read the legacy sources. The core is WinMTRNet.cpp, and it is about 450 lines. It holds struct s_nethost host[MaxHost], a fixed array of per-hop counters: addr, xmit, returned, total, last, best, worst, name. #define MAX_HOPS 30 at the top of the file is the only sizing decision in the program. Tracing spawns thirty threads with _beginthread, one per TTL, each looping on IcmpSendEcho, and a separate DnsResolverThread starts the first time a hop reveals a new address.
That design is defensible for 2011, and it is also where every limitation comes from. Because the array is fixed, a five-hop route still costs thirty threads. Because the array is fixed, the display has thirty rows, and the unreached ones are padded with No response., the filler that makes a short path look like a long broken one.
The dialogs are the other half. WinMTRDialog.cpp owns the grid, the timer and the trace lifecycle together in 1,200 lines; WinMTROptions.cpp owns the interval and size fields; WinMTRProperties.cpp is the per-hop detail popup.
The problem is that the window and the tracing code depend on each other. WinMTRDialog starts and stops traces, updates the table and saves settings. WinMTRNet sends probes and counts replies, but also reads settings from the dialog. We cannot reuse the tracing code in a console application without first removing that dependency.
There are smaller problems too. A failed probe can replace the hostname with an error message. DNS lookups have no cache or timeout, and SetWorst does not save the value passed to it. These are behaviours we need to review while porting the code.
Therefore, we split the remaster into three parts: route statistics and tracing rules, operating-system calls, and the user interface. The tracing engine asks two interfaces to send probes and look up names. This lets us run the same engine from a window, a terminal or a test.
The Domain Language
Before writing a class, we wrote a glossary. It lives in CONTEXT.md and defines the terms we use throughout the code.
The reason is that the legacy code names things after their storage. host[i] is a hop because it sits at index i; a hop with addr == 0 is empty because the memory is zeroed. Those names cannot express the distinctions the tool actually makes, so the distinctions are made in ad-hoc conditionals scattered across the file.
Therefore, we named them. A silent hop has no known address because it never replied. Trailing silence is the run of silent hops after the last responding one. The hop limit is the maximum TTL a session probes, the route length is how many hops the current snapshot contains, and the active extent is the span of TTLs the engine probes right now. Three different numbers that MAX_HOPS was doing the job of.
Each entry also carries an Avoid list, such as max rows, filler rows and tick, which exists to stop the vocabulary drifting back. That is worth more than it sounds when a coding agent writes most of the diffs, because the glossary is what keeps twenty sessions naming the same concept the same way.
The most useful term turned out to be the route snapshot: the immutable state of the whole route at one instant, published repeatedly while the trace runs. Once snapshots are immutable, the engine cannot be read while it is being written, the report renderers take a value instead of a live object, and the UI binds to something that cannot change under it mid-render. One definition removed an entire class of locking procedures that the original has to do.
The layout follows from the language. WinMtr.Core holds the domain and knows nothing about sockets. WinMtr.Infrastructure holds what touches the machine. The seam between them is only a pair of interfaces.
The DNS side is worth mentioning. The original app started an uncached OS thread per discovered address and blocked it in gethostbyaddr with no timeout. CachingNameResolver keeps a per-session cache of 4,096 entries, remembers failed lookups, and never retries on a timer, so DNS stays off the hot path.
A Console Front-End Before a Window
Next, and before any Avalonia work, we built WinMtr.Console on System.CommandLine.
The engine runs continuously, with several probes in progress at once. That is difficult to debug through a window. The CLI prints a route snapshot at each update, so we can compare the text when something looks wrong. A coding model can inspect that output too.
winmtr-cli example.com --interval 1 --hop-limit 30 --numericIt also forced the composition API to be usable by something other than the application I had in mind. Tracer.CreateTraceAsync returns a PreparedTrace carrying the resolved Target, the detected ProbeCapabilities and an IAsyncEnumerable<Route> of snapshots.
using var cancellation = new CancellationTokenSource();
CancellationToken ct = cancellation.Token;
Console.CancelKeyPress += (_, args) =>
{
args.Cancel = true;
cancellation.Cancel();
};
var tracer = new Tracer();
try
{
PreparedTrace trace = await tracer.CreateTraceAsync("example.com", ProbeSettings.Default, ct);
await foreach (Route snapshot in trace.Snapshots.WithCancellation(ct))
{
Console.WriteLine(new TextReportRenderer().Render(snapshot));
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Ctrl+C stops the trace.
}The CancellationTokenSource lets us request a stop. Its token, ct, carries that request through preparation and tracing when we press Ctrl+C.
Preparation, meaning parsing, validation, DNS and capability detection, completes before the first snapshot. Hence, the three ways it may fail surface as exceptions at one call site instead of as a broken row later.
What Changed From the Original App
I wanted to keep the tool familiar, but some behaviours needed to change. We recorded the route and report changes as architecture decision records in docs/adr/. The error model is defined in CONTEXT.md. We also chose Native AOT publishing and headless testing to keep the application easy to run and the development cycle short.
Routes grow, they are not padded. Snapshots trim trailing silence to the last responding hop, so a five-hop path is five rows. HopLimit survives as a ceiling, defaulting to 30 with a 1..255 range, because a TTL is one octet and a silent destination would otherwise grow without a stop. Underneath it, the storage and the worker set are dynamic: a fast start of 8 TTLs, then 3-TTL growth as evidence arrives. A short route now costs a handful of workers instead of thirty threads, and a fifteen-hop route converges in roughly four cadences instead of fifteen. These decisions are recorded in ADR 0001 and ADR 0002.
Errors no longer replace hostnames. In the original app, a failed probe writes its reason into the hop’s name column. We keep the address and the resolved name, store the reason separately as a ProbeErrorReason, and update the latest probe state on every probe. You can see both which router replied and what happened on the last attempt. These terms are defined in the domain glossary.
Reports gained CSV and JSON. The original app exports text and HTML. We kept both, added CSV for spreadsheets, and added JSON for tools that need the full measurements. The text report still uses fixed-width columns so it is easy to paste into a support ticket. ADR 0004 allows these formats to change: the remaster includes a separate Status column, so its reports no longer match the original output exactly.
Native AOT is a requirement. I wanted the application to start quickly and run without asking the user to install .NET. Native AOT compiles the code before we ship it, rather than when the application runs.
ADR 0007 records this choice for both the desktop app and the CLI. Therefore, our library choices, bindings and JSON serialization need to preserve AOT compatibility. The published app needs neither the .NET Framework nor a separate .NET runtime installation.
Tests run without opening desktop windows. I wanted to run the tests repeatedly without windows appearing or taking keyboard focus. Most tests use fake network responses and controlled time; the UI tests use Avalonia’s headless host to load views and check bindings. This keeps the feedback cycle short and avoids depending on a live network.
Headless testing is a convention of the test suite rather than a separate ADR. ADR 0008 later records the move to Avalonia 12 and its compatible xUnit.net v3 test host.
The Avalonia Front-End
I had wanted to build an Avalonia application for a long time. A tool with one live grid and one settings dialog is a reasonable first one.
For the MVVM layer I chose CommunityToolkit.Mvvm over ReactiveUI. Source-generated [ObservableProperty] and [RelayCommand] cost no Rx learning tax, and the project publishes with IsAotCompatible=true, where ReactiveUI has historically been the weak spot for trimming.
For the look I stayed on the stock FluentTheme. I tried FluentAvalonia first, and dropped it because its AppWindow broke the headless test host with an HWND collision and a missing symbols font. The Fluent appearance is instead two ColorPaletteResources entries, a Mica backdrop on a plain Window, and the platform UI face with the bundled Inter font as the fallback. The original was a plain Windows dialog, so a native Windows 11 appearance is the honest continuation of it, and the window follows the operating system light or dark preference.
The interesting decision was how the grid consumes snapshots. Snapshots are immutable and arrive per cadence, so the naive option is to rebuild the ObservableCollection each time. We keep an ObservableCollection<HopRowViewModel> keyed by hop index instead, and reconcile: update in place, append new hops, and retain rows that later snapshots trim.
for (int i = 0; i < hops.Length; i++)
{
if (i < Rows.Count)
UpdateLiveRow(Rows[i], hops[i]);
else
Rows.Add(CreateRow(hops[i]));
}
// Retained hop rows (ADR 0003): a row that has appeared stays for the
// session; rows beyond the current snapshot are marked, not removed.
for (int i = hops.Length; i < Rows.Count; i++)
Rows[i].IsFrozen = true;Reconciliation costs a loop and per-property change notification, and it buys row identity. Selection and scroll position survive a snapshot, which they must, because the hop-detail pane is a selection. Route equality is reference-based on its array, so there is no cheap way to skip a redundant rebuild either.
Retention is the part I want to flag as a deliberate divergence. Snapshots trim trailing silence, so the route shrinks as well as grows, and a row vanishing from under the pointer feels like a defect. Therefore, the grid keeps every row it has shown for the rest of the session, drawn in muted text. This is a presentation rule only, and every exported report stays trimmed, so a reader comparing the window against a saved report will find extra trailing rows in the window.
The grid highlights packet loss and unusually slow replies. A cell showing packet loss has a coloured background and a small bar beneath the number. An unusually high reply time gets a coloured background and a ▲ marker, so colour is not the only clue.
We only mark a slow reply after at least three replies have arrived. Its time must be at least 2.5 times the hop’s average and at least 20 ms above it. For example, with an average of 10 ms, a 30 ms reply gets a marker but a 25 ms reply does not. The rule lives in WinMtr.Core and can be tested without a window.
The TraceSession class handles starting and stopping a trace for the desktop app. An invalid target, a failed DNS lookup and a capability-check timeout each need a different message. Keeping this logic separate from the window lets us test it without loading Avalonia.
The non-integration suite now contains 759 test cases across the core, infrastructure, console and desktop projects. They use fake network responses or local test data, so they can run without network access. Tests that send real ICMP packets are kept separate and excluded from the default run.
The Cross-Platform Journey
The same network code does not have the same permissions on every operating system. On Linux and macOS, sending a custom ICMP payload can require elevated privileges. This matters when the user changes the packet size in Settings.
Before tracing starts, InitializeAsync sends a test probe to the local machine to check what is allowed. If the platform rejects the custom payload, the app verifies that the default payload works and reports PayloadSupport.Restricted. If the check times out, it reports Undetermined: no reply does not tell us whether the payload was allowed. In either case, tracing continues with the default payload.
The window explains the limitation in a short banner. On Linux, it points to setcap cap_net_raw+ep; on macOS, it suggests running with elevated privileges. The user can keep tracing with the default packet size or grant the required permissions.
Worse, unprivileged Ping on Unix may not report the intermediate router’s address on TTL expiry, which is the entire point of a traceroute. I would not present this as solved. The payload restriction is detected and handled; the intermediate-address behaviour is tracked as an explicit Pending state that only a real observation may settle, and it is the thing I want measured on real distributions before the README promises anything about it.
Making WinMTR Feel at Home on macOS
We now package the desktop application as WinMTR.app, with its icon, metadata and native libraries inside the bundle. There are separate builds for Apple Silicon and Intel. ADR 0009 records the bundle, signing policy and macOS CI coverage.
The app also gains a native menu bar with About, Preferences, report commands and standard text editing.
Closing the main window stops the trace and waits for its work to finish, but leaves WinMTR running in the Dock. Clicking the Dock icon opens a fresh window; Quit ends the application.
A ShellLifetimeCoordinator handles this so Close and Quit cannot start two competing shutdowns.
For the appearance, we kept the same views and view models and added a macOS theme pack. It changes the toolbar, menus, alternating row backgrounds and Details pane. A platform profile is selected at startup and enables the Mac theme, menus and lifetime behaviour together. The tracing engine stays the same.
The bundle is ad-hoc signed, which checks its integrity but does not establish publisher trust. It is not Developer ID signed or notarized, so Gatekeeper may still block a downloaded copy.
Settings Without the Registry
The original app keeps accepted settings and the host list in HKCU\Software\WinMTR. That is one API call on Windows and nothing at all on Linux or macOS, so it had to go.
Settings are now two JSON files under a WinMTR folder in Environment.SpecialFolder.LocalApplicationData: settings.json and history.json. Two files rather than one, because they are written at different moments, settings on confirmation and history when a target is accepted, so a damaged host list cannot cost you your probe settings.
Each write is atomic: write a temporary file, then move it over the real one. A power cut during the write cannot leave a half-written settings file, and the cost is two extra lines.
The AOT constraint shapes the serialization. Reflection-based JsonSerializer is not trim-safe, so both stores go through a source-generated JsonSerializerContext, which means adding a persisted setting is a change to the context as well as to the record.
Finally, there is a one-time, read-only import. On Windows, when no remaster file exists, the stores read HKCU\Software\WinMTR once and adopt whatever the original app left there. The remaster never writes back, so both versions may be installed side by side and the original keeps working. It runs only while the file is absent, so nothing is written until you make a real choice.
Shipping Native AOT on Five Targets
The release matrix is four runners for five runtime identifiers, because Native AOT cannot cross-compile between operating systems but may cross-compile between architectures on the same one. A single macos-latest runner therefore builds both Mac binaries, and each job runs the test suite on its own platform, so the Windows-only and Linux-only branches are tested where they matter.
The honest limitation is the grid. Avalonia.Controls.DataGrid is not trim-clean and reports IL2104 and IL3053 against itself, so the project demotes those two warnings for the AOT publish and smoke-tests the published application after each Avalonia upgrade.
Installation
Download the archive for your platform from the releases page. The release workflow builds win-x64, linux-x64, linux-arm64, osx-x64 and osx-arm64. Extract it and run winmtr for the desktop app on Windows or Linux, or winmtr-cli for the terminal. No separate .NET runtime installation is needed.
On macOS, choose osx-arm64 for Apple Silicon or osx-x64 for Intel. Move WinMTR.app to Applications and open it in Finder. Keep the bundle intact because it contains the libraries the application needs.
If Gatekeeper blocks a download you trust, you may clear the quarantine flag on that bundle:
xattr -dr com.apple.quarantine /Applications/WinMTR.appWindows binaries are not publisher-signed, so SmartScreen may show a warning. For a download you trust, choose More info and then Run anyway. Building from source needs the .NET 10 SDK:
git clone https://github.com/kzagoris/winmtr-remaster.git
cd winmtr-remaster
dotnet run --project src/WinMtr.DesktopThe sources remain under GPL v2, like the original app.
What the Model Actually Did
The architecture, the glossary and the ADRs above are mine. The code that fills them in is mostly not.
I built most of it with Muse Spark 1.3 on the free tier that OpenCode Zen provides, under OpenCode 2. I wanted a small, fast model with many iterations rather than the frontier models I had already been working with, and this codebase is a fair test of that. It is not complicated overall, but it has two genuinely fiddly parts: the concurrency around the worker set, and the resolver cache that exists to avoid hammering DNS.
Both came out fine, and I think the reason is that the limits were decided before the model saw them. The glossary fixed the vocabulary, the ADRs fixed the trade-offs, and the console front-end gave the model a way to check its own work. A port is unusually good ground for this, because the previous implementation answers most questions about intended behaviour without me.
The harness deserves its share of the credit. I found OpenCode straightforward to drive, and its diff editor is an amazing feature.
Altogether, I think the interesting lesson of this port is that almost none of the work was network code. IcmpSendEcho to System.Net.NetworkInformation.Ping is an afternoon. The fifteen years are in the fixed array, the registry key, the dialog that owns the lifecycle, and the error string written into the hostname column: the incidental decisions that a good tool accumulates while nobody is looking. Naming the domain before rebuilding it is what made those visible, and I would start there again, under the assumption that the original is readable enough to be treated as a specification.