Files
RemSound/src/RemSound.App/Program.cs
T
EdnunpandClaude Opus 4.8 0df2a576fc Release v4.8: fix rare crash from endpoint thrash on a dual-address peer (#16)
The same singer hit a rare crash: their transmitter (COMP3) was reachable at two
addresses at once — the VPN address 10.8.0.1 they chose and that machine's wireless
192.168.3.245 — and the discovery-driven endpoint-follow ping-ponged the connection
between the two (the log shows four moves in 58 ms) right where the process died with
no shutdown line, no managed exception, no dialog: a hard crash from the receiver
audio-session teardown/rebuild churn the thrash caused.

Fix: the follow loop now never moves off an endpoint that's still answering heartbeats,
only follows once the current one has been unreachable for a sustained grace period
(6 s), only to an address that is itself answering, and never more than once per cooldown
(15 s) — so it can't thrash, and a peer reached on a working address stays put (honours
the singer's "just stay on 10.8.0.1"). New endpointUnreachableSinceUtc + lastEndpointMoveUtc
state; genuine DHCP/network moves are still followed a few seconds later.

Also: a global crash handler (Program.WriteCrashReport on AppDomain.UnhandledException +
TaskScheduler.UnobservedTaskException) writes a crash-*.txt into the logs folder, so a
future "RemSound just vanished" report leaves a stack behind. Removed a stale doc comment
left over from the v4.7 adoption removal. Manual gains a "RemSound closed unexpectedly"
troubleshooting entry. Version 4.8; About + RELEASE_NOTES updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:57:49 +01:00

503 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Runtime;
using System.Windows.Forms;
using RemSound.Core;
namespace RemSound.App;
internal static class Program
{
// The MainForm currently open in the loop below, or null between profile-switch
// iterations. Tracked so the single-instance coordinator's activation callback (which
// fires on a background thread when a second copy asks us to surface) can reach the live
// window. volatile for cross-thread visibility; RestoreFromTray marshals to the UI thread.
private static volatile MainForm? activeMainForm;
// Writes an otherwise-fatal exception to a timestamped crash file in the logs folder, so a
// "RemSound just disappeared, no dialog" report (#16) leaves a stack behind to diagnose instead
// of nothing. Best-effort and self-contained — a crash handler must never throw.
private static void WriteCrashReport(string source, Exception? ex)
{
try
{
var dir = AppConfig.LogsDirectory;
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"crash-{DateTime.Now:yyyyMMdd-HHmmss-fff}.txt");
var report =
$"RemSound crash report{Environment.NewLine}" +
$"Version: {typeof(Program).Assembly.GetName().Version}{Environment.NewLine}" +
$"Time: {DateTime.Now:O}{Environment.NewLine}" +
$"Source: {source}{Environment.NewLine}{Environment.NewLine}" +
(ex?.ToString() ?? "(no exception object)");
File.WriteAllText(path, report);
}
catch { /* a crash handler must never throw */ }
}
[STAThread]
private static void Main(string[] args)
{
// The auto-updater relaunches a temp copy of the NEW RemSound.exe in this mode to swap the
// new files over the install while the old copy exits (see UpdateApplier / RemSoundUpdater).
// Handle it first and return: this process is the installer, not a normal launch, so it must
// not touch the single-instance lock, audio devices, or the migration steps below.
if (args.Length > 0 && Array.Exists(args, a => string.Equals(a, "--apply-update", StringComparison.OrdinalIgnoreCase)))
{
UpdateApplier.Run(args);
return;
}
// Capture otherwise-fatal background-thread exceptions to a crash file, so a "RemSound just
// vanished with no dialog" report (#16) leaves a stack behind instead of nothing. Best-effort.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
WriteCrashReport("AppDomain.UnhandledException", e.ExceptionObject as Exception);
TaskScheduler.UnobservedTaskException += (_, e) =>
{
WriteCrashReport("TaskScheduler.UnobservedTaskException", e.Exception);
e.SetObserved();
};
// --config-dir <folder> (test / portable isolation): redirect ALL user state - config,
// profiles, logs, cue sounds - to an explicit folder for THIS process only. Applied first,
// before the layout migration and sound consolidation below read or write the default
// location, so a smoke test can run a real build without touching the user's settings
// (smoke-test brief, safety rule 1).
if (CommandLine.TryGetConfigDir(args, out var configDir))
{
AppConfig.SetUserDataDirectoryOverride(configDir);
}
// --silent: make this launch play no cue sounds at all (startup, connect, checkbox,
// tab-switch, ...) and skip the front-most "missing sound file" warning. The automated test
// harness passes it so its throwaway launches stay completely quiet instead of chiming a
// startup cue (or popping a dialog) onto whoever happens to be at the screen. Set up front,
// before consolidation or the startup cue can fire.
if (Array.Exists(args, a => string.Equals(a, "--silent", StringComparison.OrdinalIgnoreCase)))
{
CuePlayer.GloballyMuted = true;
}
// SustainedLowLatency tells the GC to avoid full (gen 2) collections while audio is streaming.
// Gen 0/1 collections still happen but are sub-millisecond; the long pauses that were causing
// the receiver to fall behind in clusters of 4-5 underruns at a time were almost certainly
// gen 2 sweeps. This trades a bit of memory headroom (the GC will hold on to garbage longer)
// for dramatically more predictable timing — exactly the trade real-time audio wants.
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
ApplicationConfiguration.Initialize();
// Consolidate every older layout (loose files, or the interim config\ folder) into the single
// "user settings and logs" folder before anything reads config/profiles/logs. Idempotent +
// best-effort; upgrades users from any older build. Shown to the user once if files moved.
var layoutMigration = RemSound.Core.AppConfig.MigrateLegacyLayoutIfNeeded();
// Cue sounds no longer live in a sounds\ folder at all (the shipped defaults are install-side
// in AppConfig.SoundsDirectory = "default sounds\" now, so an update always refreshes them).
// Delete BOTH defunct old sounds folders an upgrader might still have, whatever version they
// came from. Best-effort + idempotent.
RemoveLegacySoundFolders();
// Remove cue WAVs (and their .sfk peak files) left loose in the install ROOT by pre-
// 2026-05-28 builds, where the cues lived next to RemSound.exe before they moved into
// sounds\. An update copies the new sounds\ tree but never purges, so it never deletes
// these orphans — they just linger in the root. Best-effort + idempotent:
// a no-op once they're gone. 2026-06-08.
CleanUpLegacyRootSounds();
// Command-line interface (Sensor-Readout-style). "Do-and-exit" commands (--devices,
// --selftest, --diagnostics, --version, --log, --close, --help) run here — before the
// single-instance lock and before any window — and terminate the process. Otherwise we
// collect launch overrides (--profile / --connect / --minimized) and continue the normal
// GUI start below, applying them as we resolve the profile.
var cliExit = CommandLine.Process(args, out var cli);
if (cliExit is { } cliCode) Environment.Exit(cliCode);
if (cli.StartMinimized) MainForm.startNextInstanceMinimized = true;
// Single-instance guard. RemSound must never run as two copies at once: with the
// auto-updater relaunching the app, a copy that didn't exit cleanly used to leave two
// (then more) copies running, each playing received audio — Andre's "stacked and
// stacked", deafening-audio runaway (2026-05-30). The lock makes that structurally
// impossible. Acquired BEFORE anything user-visible so a second copy bows out (or
// takes over a stuck one) before it ever touches audio devices or the network.
using var instance = new SingleInstanceCoordinator();
if (!instance.TryAcquire(TimeSpan.Zero))
{
// If we can't even ask the user (dialog failed to show), the safe answer is "don't
// start a second copy" — bowing out is always safer than risking a duplicate.
SingleInstanceDecision decision;
try { decision = SingleInstanceDialog.Ask(); }
catch { return; }
switch (decision)
{
case SingleInstanceDecision.SwitchToRunning:
SingleInstanceCoordinator.SignalExistingToActivate();
return;
case SingleInstanceDecision.Cancel:
return;
case SingleInstanceDecision.ForceClose:
var cleared = SingleInstanceCoordinator.ForceCloseOtherInstances();
// Take the lock now the others should be gone. Allow a few seconds in case
// a killed copy is slow to release the abandoned mutex / its audio devices.
if (!instance.TryAcquire(TimeSpan.FromSeconds(5)))
{
ForegroundDialog.Show(owner => MessageBox.Show(
owner,
cleared
? "RemSound closed the other copy but couldn't start cleanly. Please launch RemSound again."
: "RemSound couldn't close the copy that's already running — it may be running as administrator. Close it from Task Manager (or restart Windows), then try again.",
"RemSound is already running",
MessageBoxButtons.OK, MessageBoxIcon.Warning));
return;
}
break;
}
}
// We hold the single-instance lock — THIS copy has taken over (any stuck older copy was
// force-closed just above). Play the one-shot startup cue here: after the take-over
// decision is settled and before the profile picker/load, so if a copy was already
// running you only hear it once the NEW process is in charge. The earlier "switch to the
// running copy" / "cancel" paths returned before this point, so a copy that bowed out
// never plays it. Fire-and-forget WaveOut, so it sounds even when we launch into the tray.
PlayStartupCueIfEnabled();
// We hold the single-instance lock. Listen for a later copy asking us to surface, and
// route that request to whichever main window is open at the time.
instance.StartActivationListener();
instance.ActivateRequested += () => activeMainForm?.RestoreFromTray();
// Best-effort: clear leftover update temp stages (and any relics of the old batch updater).
// We hold the single-instance lock here, so only the live copy does this — no sibling race.
RemSoundUpdater.CleanUpUpdateStages();
// F1 anywhere = open the bundled manual. Installed *before* the first ShowDialog so
// it works on the profile picker (the very first thing the user sees). The filter
// is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free.
HelpLauncher.Install();
// Audible typing feedback: a soft click on each keystroke in any edit field, plus a distinct
// passkey sound on password fields. Machine-wide toggle (on by default). Installed app-wide
// here - after the single-instance guard, before the profile picker - so it works on the
// picker and every dialog. Best-effort: inert if the click sounds can't load.
KeyClickService.Initialize(AppConfig.Load().EnableKeyboardClicks);
Application.ApplicationExit += (_, _) => KeyClickService.Shutdown();
// Tick/untick sounds for checkbox toggles app-wide (CheckSoundService) and the tab-switch
// cue (TabSwitchSoundService). Loaded here; reloaded by MainForm.ReloadAllCueSounds whenever
// cue settings change in Preferences.
CheckSoundService.Reload();
TabSwitchSoundService.Reload();
// One-time "your settings moved" notice — only the launch that actually relocated files
// shows it (idempotent migration ⇒ MovedAnything is false on every later launch). Shown
// here, after the guard and before the profile picker, so the user reads it once up front.
// Skipped on a --silent (automated/throwaway) launch: its TaskDialog dings at and pops over
// whoever's at the screen during a test, and a throwaway instance needn't announce a move.
if (layoutMigration.MovedAnything && !CuePlayer.GloballyMuted)
{
ShowLayoutMigrationNotice();
}
// Outer loop: lets ProfileManagementDialog change the profiles folder mid-session.
// When that happens, MainForm sets ReloadFromScratch=true, we re-read AppConfig, build
// a fresh ProfileStore, and re-show ProfileSelectionDialog so the user picks a profile
// (or blank template) from the *new* folder. Inner loop handles the cheaper "switch to
// a profile in the same folder" case.
while (true)
{
var appConfig = AppConfig.Load();
var store = appConfig.CreateStore();
Profile? profile;
string? title;
// Auto-load shortcut: two paths.
//
// (a) Resume-after-update sentinel — a one-shot file written by the updater
// just before it relaunches RemSound.exe (see RemSoundUpdater
// ResumeProfileSentinelName). Holds the title of whichever profile was
// loaded at the moment the update fired. If present, we load that profile
// silently and delete the sentinel — so a silent or mid-session update
// restores the same session the user was running, without dropping them
// at the picker. This takes precedence over StartWithProfileTitle because
// a mid-session update may have moved the user away from their configured
// startup profile.
//
// (b) AppConfig.StartWithProfileTitle — the persistent "Start with a specific
// profile" preference set via the Startup behaviour dialog. Loaded if (a)
// didn't fire. Combined with the Windows auto-start registry entry and the
// StartMinimised flag, it lets the user boot a machine and have RemSound
// up and streaming with no clicks.
//
// Either path falls through to the normal picker if the named profile no longer
// exists (deleted since it was selected, or the profiles folder changed) so the
// user isn't stuck.
Profile? autoLoaded = null;
string? autoLoadedTitle = null;
// CLI overrides take precedence over the resume sentinel and the "start with profile"
// setting. --profile loads a named profile; --connect with no --profile starts blank
// (the requested peer is added to whatever profile loads, further below).
if (cli.ProfileName is not null)
{
try { autoLoaded = store.Load(cli.ProfileName); if (autoLoaded is not null) autoLoadedTitle = cli.ProfileName; }
catch { /* fall through to the normal resolution */ }
}
else if (cli.ForceBlankProfile)
{
autoLoaded = Profile.NewBlank();
autoLoadedTitle = null;
}
var resumeSentinelPath = Path.Combine(AppContext.BaseDirectory, RemSoundUpdater.ResumeProfileSentinelName);
string? resumeTitle = null;
if (File.Exists(resumeSentinelPath))
{
try { resumeTitle = File.ReadAllText(resumeSentinelPath).Trim(); }
catch { resumeTitle = null; }
// Delete the sentinel unconditionally — it's a one-shot. If the load fails
// below, the user gets the picker on this launch and a normal start next
// time, rather than the sentinel re-firing on every relaunch forever.
try { File.Delete(resumeSentinelPath); } catch { /* ignore */ }
}
if (autoLoaded is null && !string.IsNullOrWhiteSpace(resumeTitle))
{
try
{
autoLoaded = store.Load(resumeTitle!);
if (autoLoaded is not null) autoLoadedTitle = resumeTitle;
}
catch { /* fall through to StartWithProfileTitle / picker */ }
}
if (autoLoaded is null && !string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
{
try
{
autoLoaded = store.Load(appConfig.StartWithProfileTitle!);
if (autoLoaded is not null) autoLoadedTitle = appConfig.StartWithProfileTitle;
}
catch { /* fall back to picker */ }
}
if (autoLoaded is not null)
{
profile = autoLoaded;
title = autoLoadedTitle;
}
else
{
using var dialog = new ProfileSelectionDialog(store);
if (dialog.ShowDialog() != DialogResult.OK) return;
// ProfileSelectionDialog can have changed the folder via its Browse button;
// if so, it's already saved AppConfig and rebuilt its internal store. Pick up
// its post-Browse store reference for the rest of the session.
store = dialog.Store;
profile = dialog.SelectedProfile;
title = dialog.SelectedTitle;
}
// Apply --connect: add the requested peer address(es) to the loaded profile so MainForm
// selects and connects to them on startup. Stored as plain address strings, the same
// shape "Add peer by IP" produces (the audio port is the default unless one is given).
if (cli.ConnectPeers.Count > 0 && profile is not null)
{
foreach (var ep in cli.ConnectPeers)
{
var addr = ep.Port == RemPacket.DefaultPort ? ep.Address.ToString() : $"{ep.Address}:{ep.Port}";
if (!profile.RememberedPeers.Contains(addr)) profile.RememberedPeers.Add(addr);
if (!profile.SelectedConnectedPeers.Contains(addr)) profile.SelectedConnectedPeers.Add(addr);
}
}
// Switch-profile loop: when the user clicks "Switch to profile" in the Manage
// Profiles dialog, the form sets NextProfileTitleToLoad and closes; we re-open
// MainForm under the newly chosen profile. Null = user closed the form normally
// → exit. ReloadFromScratch = the user changed the profiles FOLDER mid-session,
// so we break out of this inner loop and let the outer loop redo the selection
// dialog under the new folder.
var reloadFromScratch = false;
string? nextPath = null;
while (true)
{
// Opening an ASIO driver is slow (1-3 s) and happens synchronously inside
// MainForm construction. Show a "Loading audio driver" splash — on its own
// thread, so it stays painted while this thread is busy — so startup doesn't
// look hung. No-op for WASAPI-only profiles (construction is near-instant).
// Skip the loading splash when this rebuild is a quick-profile-switch that's
// staying in the tray — popping a splash up in front of the user's current app
// defeats the point of keeping RemSound minimised, and the switch cue already
// gave them feedback. Normal launches and visible switches still show it.
var splash = MainForm.startNextInstanceMinimized ? null : AsioLoadingSplash.StartIfNeeded(profile);
using var form = new MainForm(store, profile, title, nextPath);
// Expose the live window to the single-instance activation callback (a second
// copy choosing "switch to the running copy" signals us to surface this form).
activeMainForm = form;
splash?.Dismiss();
Application.Run(form);
activeMainForm = null;
if (form.ReloadFromScratch)
{
reloadFromScratch = true;
break;
}
// Path-based reload (File → Open profile from a path that may be outside
// the active store's BaseDirectory) takes precedence — read JSON directly
// from that path. Falls back to title-based store.Load when no path is set
// (e.g. legacy switch-by-title flows that pre-date the path tracking).
nextPath = form.NextProfilePathToLoad;
var nextTitle = form.NextProfileTitleToLoad;
if (form.LoadBlankTemplateNext)
{
// File → New profile: rebuild on a fresh blank template, no saved profile.
profile = Profile.NewBlank();
title = null;
nextPath = null;
}
else if (!string.IsNullOrEmpty(nextPath))
{
try
{
var json = File.ReadAllText(nextPath);
profile = System.Text.Json.JsonSerializer.Deserialize<Profile>(json) ?? Profile.NewBlank();
title = !string.IsNullOrEmpty(nextTitle)
? nextTitle
: Path.GetFileNameWithoutExtension(nextPath);
}
catch
{
// Malformed / unreadable JSON. Fall back to blank template under
// whatever title we have, rather than crashing the loop.
profile = Profile.NewBlank();
title = !string.IsNullOrEmpty(nextTitle)
? nextTitle
: Path.GetFileNameWithoutExtension(nextPath);
nextPath = null;
}
}
else if (!string.IsNullOrEmpty(nextTitle))
{
title = nextTitle;
profile = store.Load(nextTitle) ?? Profile.NewBlank();
}
else
{
return; // form closed normally — exit app
}
}
if (!reloadFromScratch) return;
}
}
/// <summary>One-time, Windows-native notice telling the user their config/profiles were moved
/// into the new "user settings and logs" folder. Only called when a real migration happened. TaskDialog
/// (not a hand-rolled Form) so a screen reader reads the whole message automatically.</summary>
private static void ShowLayoutMigrationNotice()
{
var page = new TaskDialogPage
{
Caption = "RemSound files location",
Heading = "Your RemSound files have moved into one folder",
Text = "To keep the RemSound folder tidy and stop updates from ever touching your own files, "
+ "this update moved everything this machine owns into a single folder inside RemSound "
+ "called \"user settings and logs\":\n\n"
+ "- Your settings (global config)\n"
+ "- Your saved profiles\n"
+ "- Your logs\n"
+ "- Your cue sounds\n\n"
+ "Nothing was lost and RemSound works exactly as before. From now on, RemSound updates "
+ "leave that folder completely untouched. You will only see this message once.",
Icon = TaskDialogIcon.Information,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
// Give the notice a momentary top-most, foreground owner so it opens FRONT and CENTRE —
// RemSound may have launched straight into the tray (auto-start / start-minimised), and a
// parent-less TaskDialog can otherwise open behind everything where a screen-reader user
// can't read it.
try { ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)); }
catch { /* a notice must never stop RemSound from starting */ }
}
/// <summary>Delete cue WAVs and their .sfk peak files left loose in the install ROOT by
/// pre-2026-05-28 builds (the cues moved into <c>sounds\</c> then; an update copies the new
/// tree but never removes the old root copies). Best-effort and idempotent — runs
/// every launch and no-ops once the orphans are gone. Only the known default cue names are
/// touched, never anything else in the folder.</summary>
private static void CleanUpLegacyRootSounds()
{
try
{
var root = AppContext.BaseDirectory;
string[] cueBaseNames =
{
"connect", "disconnect", "record start", "record stop",
"save", "profile", "profile menu open", "update",
};
foreach (var baseName in cueBaseNames)
{
foreach (var fileName in new[] { baseName + ".wav", baseName + ".sfk", baseName + ".wav.sfk" })
{
try
{
var path = Path.Combine(root, fileName);
if (File.Exists(path)) File.Delete(path);
}
catch { /* a locked / unremovable file must never stop startup */ }
}
}
}
catch { /* never let cleanup disturb startup */ }
}
/// <summary>Delete the two defunct old cue-sounds folders an upgrader might still have on disk,
/// whichever version they came from. Sounds now live install-side in
/// <see cref="AppConfig.SoundsDirectory"/> (<c>&lt;exe&gt;\default sounds\</c>), which updates
/// always refresh; both old locations are dead and only cause confusion / stale reads if left:
/// * <c>&lt;exe&gt;\sounds\</c> — the install-side folder cue WAVs lived in from ~v3.1 to v3.4.
/// A user jumping STRAIGHT from that era to this version never ran the v3.5 consolidation that
/// used to move-and-delete it, so it can still be sitting there.
/// * <c>...\user settings and logs\sounds\</c> — the per-user folder cues lived in from v3.5 to
/// v3.9.1, with a never-overwrite seed that meant a changed default could never reach an
/// existing user (the whole reason for the 2026-06-13 move).
/// Best-effort + idempotent — a no-op once they're gone. The user's REAL custom sounds were never
/// in either folder (they're explicit Browse-picked file paths elsewhere), so nothing is lost.</summary>
private static void RemoveLegacySoundFolders()
{
foreach (var legacy in new[]
{
Path.Combine(AppContext.BaseDirectory, "sounds"), // ~v3.1v3.4 install-side
AppConfig.LegacyUserSoundsDirectory, // v3.5v3.9.1 per-user
})
{
try { if (Directory.Exists(legacy)) Directory.Delete(legacy, recursive: true); }
catch { /* leave it if locked / unreadable — it's just unused clutter now */ }
}
}
/// <summary>Play the startup cue once if the machine-wide setting is on. Resolves the WAV the
/// same way the in-app cues do — a user-set custom path (machine-wide, in <see cref="AppConfig"/>)
/// if it exists on disk, otherwise the bundled <c>default sounds\start up.wav</c>. Read straight from
/// AppConfig because no profile (and therefore no settings store) is loaded yet at this point
/// in startup. Best-effort: a cue must never stop RemSound from starting.</summary>
private static void PlayStartupCueIfEnabled()
{
try
{
var cfg = AppConfig.Load();
if (!cfg.EnableStartupCue) return;
var custom = cfg.StartupCueCustomPath;
// Custom override wins; otherwise the chosen default variant ("start up 1.wav" etc).
var path = !string.IsNullOrWhiteSpace(custom) && File.Exists(custom)
? custom
: CueSounds.ResolveDefaultPath(MainForm.CueId.Startup, "start up.wav", cfg);
if (path is null || !File.Exists(path)) return;
new CuePlayer(path).Play();
}
catch { /* a startup cue must never disturb startup */ }
}
}