Skip to content

RR1-T102 Windows app base#14

Closed
ucswift wants to merge 1 commit into
masterfrom
develop
Closed

RR1-T102 Windows app base#14
ucswift wants to merge 1 commit into
masterfrom
develop

Conversation

@ucswift

@ucswift ucswift commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added a new shell experience with navigation, tray support, and clearer start/stop controls.
    • Introduced dashboard, operations, configuration, devices, logs, and about screens.
    • Added live status indicators, audio level meters, sparkline charts, and improved visual badges.
  • Bug Fixes

    • Improved service state reporting so connection and receiving indicators stay accurate.
    • Enhanced log display and scrolling for easier monitoring.
    • Improved device and configuration handling, including safer secret display and startup settings.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR replaces the old relay window with a DI-backed WPF shell, adds about/configuration/dashboard/operations/logs/devices views and view-models, introduces shared controls and converters, and updates relay engine logging, configuration, and service state handling.

Changes

Relay desktop shell and service stack

Layer / File(s) Summary
Build and logging foundation
Directory.Build.props, Resgrid.Audio.Core/AudioEvaluator.cs, Resgrid.Audio.Core/WatcherAudioStorage.cs, Resgrid.Relay.Engine/Configuration/RelayConfiguration.cs, Resgrid.Relay.Engine/Logging/*
NET10_0_WINDOWS is added for net10.0-windows, core audio classes switch to ILogger, layered relay configuration is added, and UI log record/bus/sink types are introduced.
Relay service lifecycle
Resgrid.Audio.Core/Radio/RadioBridge.cs, Resgrid.Relay.Engine/Services/*, Resgrid.Relay.Engine/Voice/*
Relay service execution now throws on non-zero exit codes, rejects overlapping starts, and updates connection-state reporting for SMTP, audio import, dispatch voice, radio, and radio bridge receive handling.
Shell bootstrap and navigation
Resgrid.Audio.Relay/App.xaml, Resgrid.Audio.Relay/App.xaml.cs, Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj, Resgrid.Audio.Relay/ShellWindow.*, Resgrid.Audio.Relay/ViewModels/ShellViewModel.cs, Resgrid.Audio.Relay/MainWindow.*, Resgrid.Audio.Relay/Properties/*, Resgrid.Audio.Relay/Resources/*, Resgrid.Audio.Relay/Skins/*
The relay app now builds a DI container, hosts a single-instance WPF shell with tray behavior, and replaces the legacy main window and app resources.
About screen and startup toggle
Resgrid.Audio.Relay/ViewModels/AboutViewModel.cs, Resgrid.Audio.Relay/Views/AboutView.*
The About screen view-model and view expose app metadata, external links, and Windows Run-key startup persistence.
Shared controls and converters
Resgrid.Audio.Relay/Controls/*, Resgrid.Audio.Relay/Converters/*
Reusable WPF converters and the level meter, sparkline, and status pill controls are added for state and audio display binding.
Configuration editor
Resgrid.Audio.Relay/Services/ConfigurationService.cs, Resgrid.Audio.Relay/ViewModels/ConfigurationViewModel.cs, Resgrid.Audio.Relay/Views/ConfigurationView.*
The configuration service and editor load, validate, save, mask secrets, and drive the Windows-only tune meter workflow.
Dashboard and operations screens
Resgrid.Audio.Relay/Services/RelayController.cs, Resgrid.Audio.Relay/ViewModels/DashboardViewModel.cs, Resgrid.Audio.Relay/ViewModels/OperationsViewModel.cs, Resgrid.Audio.Relay/ViewModels/ModeCardViewModel.cs, Resgrid.Audio.Relay/ViewModels/ServiceTileViewModel.cs, Resgrid.Audio.Relay/Views/DashboardView.*, Resgrid.Audio.Relay/Views/OperationsView.*
Controller-backed dashboard and operations views show running services, per-mode validation, and live service tiles.
Logs and devices screens
Resgrid.Audio.Relay/Services/DeviceEnumerationService.cs, Resgrid.Audio.Relay/ViewModels/DevicesViewModel.cs, Resgrid.Audio.Relay/Views/DevicesView.*, Resgrid.Audio.Relay/ViewModels/LogsViewModel.cs, Resgrid.Audio.Relay/Views/LogsView.*
Device enumeration and log buffering back the devices and logs pages, including refresh, auto-scroll, and clipboard export behavior.

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant ServiceCollection
  participant ShellWindow
  participant ShellViewModel
  participant RelayController
  App->>ServiceCollection: BuildServiceProvider() registers UiLogBus, RelayController, views, and view-models
  App->>ShellWindow: resolve and show the shell window
  ShellWindow->>ShellViewModel: bind DataContext
  ShellViewModel->>RelayController: Start(_configuration.Current)
  ShellViewModel->>RelayController: StopAllAsync()
  App->>RelayController: StopAllAsync() in OnExit
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related PRs

  • Resgrid/Relay#12 — Both PRs touch Resgrid.Audio.Core/Radio/RadioBridge.cs; this PR changes OnRadioAudio receiving-state behavior while the other adds the bridge implementation.
  • Resgrid/Relay#13 — Both PRs touch the radio RX/TX state path and related relay wiring; this PR adjusts Receiving forwarding logic while the other refactors Receiving/Transmitting handling around RadioBridge.

Poem

I hopped through logs and windows bright,
And tuned the shells to hum just right.
With brushes, pills, and meters new,
I sniffed each signal, clean and true.
Thump! The relay dances through the night.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is too generic and ticket-like to clearly describe the main change in the pull request. Rename it to a specific summary of the main change, such as introducing the Windows app shell/base for the relay UI.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

🧹 Nitpick comments (1)
Resgrid.Audio.Relay/App.xaml.cs (1)

14-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mark the desktop assembly as Windows-only.

The project is a Windows WPF shell, but without a SupportedOSPlatform annotation CA1416 keeps treating calls in this assembly as cross-platform. Add the annotation once at assembly scope so ApplicationThemeManager, NavigationView, and TaskbarIcon call sites stop carrying analyzer noise.

Suggested change
 using System;
 using System.Linq;
+using System.Runtime.Versioning;
 using System.Threading;
 using System.Windows;
 ...
+[assembly: SupportedOSPlatform("windows")]
+
 namespace Resgrid.Audio.Relay
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Relay/App.xaml.cs` around lines 14 - 30, Mark the WPF shell
assembly as Windows-only by adding a SupportedOSPlatform annotation at assembly
scope so CA1416 stops flagging Windows-specific APIs. Apply it once for the
Resgrid.Audio.Relay assembly, near the App/Application entry point area, so
calls like ApplicationThemeManager, NavigationView, and TaskbarIcon are
recognized as Windows-only without needing per-call suppressions.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Resgrid.Audio.Relay/App.xaml.cs`:
- Around line 121-143: `App.OnExit` is doing asynchronous work in an `async
void` override, which can return before cleanup runs and skip the `finally`
block. Move the `RelayController.StopAllAsync` shutdown sequence out of `OnExit`
and into the explicit quit flow in `ShellWindow` before
`Application.Current.Shutdown()`, then make `OnExit` synchronous so it only
handles disposal and final cleanup like `Log.CloseAndFlush` and
`_singleInstanceMutex.Dispose`.

In `@Resgrid.Audio.Relay/Controls/Sparkline.cs`:
- Around line 64-122: The Sparkline control only redraws when Values is
reassigned, so in-place updates to an ObservableCollection<double> are missed.
Update Sparkline to handle INotifyCollectionChanged in
ValuesProperty/OnValuesChanged by subscribing to the new collection and
detaching from the old one, then calling Redraw when the collection changes;
keep Redraw as the rendering entry point and preserve the existing
OnValuesChanged behavior for initial assignment.

In `@Resgrid.Audio.Relay/Converters/BoolToVisibilityConverter.cs`:
- Around line 17-30: BoolToVisibilityConverter only applies Invert in Convert,
so ConvertBack still returns the non-inverted boolean and breaks two-way
bindings. Update ConvertBack to mirror the same inversion logic used in Convert,
using the Invert property to flip the Visibility.Visible/Collapsed mapping
before returning the bool result.

In `@Resgrid.Audio.Relay/Converters/SecretMaskConverter.cs`:
- Around line 16-21: The SecretMaskConverter.Convert method is still leaking
approximate secret length by varying the mask based on the input size; update it
so any non-empty string always returns the same fixed bullet mask length, while
keeping the empty/null behavior unchanged. Use the existing SecretMaskConverter
symbol to locate the change and remove the Math.Min/Math.Max length-based logic
so stored secrets render identically regardless of actual value length.

In `@Resgrid.Audio.Relay/Services/RelayController.cs`:
- Around line 130-159: The shutdown flow in RelayController should keep the
service tracked until stop/cleanup fully completes, because removing it from
_entries before Cancel, StopAsync, and RunTask finish lets IsRunning and
OverallState go idle too early. Update the stop path around the service entry
handling in RelayController so the entry is only removed in the finally block
after await service.StopAsync and awaiting entry.RunTask, or introduce an
explicit stopping state in the entry to prevent Start from reopening while the
relay is still shutting down.

In `@Resgrid.Audio.Relay/ViewModels/AboutViewModel.cs`:
- Around line 83-98: The startup registry logic is guarded by the wrong
preprocessor symbol, so the Windows-specific branches in ReadRunAtStartup and
WriteRunAtStartup never compile in the intended target. Update the conditional
compilation in AboutViewModel to use `#if` WINDOWS instead of `#if` NET10_0_WINDOWS,
and remove the redundant OperatingSystem.IsWindows() check inside that block
while keeping the registry access behavior unchanged.

In `@Resgrid.Audio.Relay/ViewModels/ConfigurationViewModel.cs`:
- Around line 502-527: Dispose the tune cancellation source after each tuning
session, not only when the view model is disposed. In the tune start/stop flow
in ConfigurationViewModel, where _tuneCts is created and used with
RadioMode.RunTuneAsync, make sure the previous CTS is disposed when a session
ends and before replacing it on the next start. Update the tuning lifecycle so
repeated calls to the tune method do not leave old CancellationTokenSource
instances undisposed.
- Around line 258-365: LoadFromOptions currently leaves stale values in
section-backed properties when optional subsections like Smtp, Voice, Radio,
Recorder, DispatchVoice, Tts, or Telemetry are missing. Update
ConfigurationViewModel.LoadFromOptions to reset those fields to their defaults
before each conditional block, or explicitly clear them in the null path, so
removed sections do not persist across reloads; use the existing property groups
and helpers like MaskIfPresent and JoinDomains to locate the affected
assignments.
- Around line 457-460: The save path in ConfigurationViewModel is using the
layered Reload() snapshot, which can persist RESGRID__RELAY__* overrides into
the user config file. Update the save flow around ApplyToOptions and
RelayConfiguration.Save so it starts from a disk-only configuration snapshot
instead of _configuration.Reload(), then apply the edited values and save that
result. Keep the fix localized to the ConfigurationViewModel configuration save
logic and preserve unknown/unedited fields without carrying over env-merged
values.
- Around line 66-96: The save flow in ConfigurationViewModel should validate the
full Resgrid API options contract, not just the annotated view-model properties.
Build the options object, run its contract validation before calling
_configuration.Save(options), and prevent persistence when ClientSecret or the
grant-type-specific token/key requirements are missing. Use the existing Save
command/configuration path and surface any validation failure through
StatusMessage so the user sees the error immediately.

In `@Resgrid.Audio.Relay/ViewModels/LogsViewModel.cs`:
- Around line 133-137: Clear currently only empties Entries, so queued backlog
in the pump is re-added on the next flush. Update the LogsViewModel.Clear
command to also reset the buffered state used by the reader/pump, including
_pending and any unread items from _bus.Reader, so a user clear fully discards
the current backlog. Make the fix in the Clear method and any related
flush/buffering logic that appends items back into Entries.
- Around line 60-62: The MinimumLevel filter in LogsViewModel only affects newly
appended batches, so changing the filter does not rebuild the visible list from
prior records. Add a retained history/ring buffer of recent log records, then
implement OnMinimumLevelChanged to repopulate Entries from that history using
the current MinimumLevel. Update the append path that currently filters into
Entries so it stores all recent records in the buffer first and uses the same
filtering logic when rebuilding.

In `@Resgrid.Audio.Relay/ViewModels/OperationsViewModel.cs`:
- Around line 87-92: StartMode is calling the throwing _controller.Start path
without first checking the mode validation or overlapping start state. Update
OperationsViewModel.StartMode to guard with the existing Validate(mode) and
IsRunning(mode) checks before invoking _controller.Start(BuildOptions(mode)),
and keep invalid or duplicate starts inside the view-model’s validation flow
instead of letting exceptions escape.

In `@Resgrid.Audio.Relay/Views/DevicesView.xaml`:
- Around line 33-58: The audio device cards currently render as blank panels
when InputDevices or OutputDevices has no items, unlike the serial and HID
sections that show a clear empty-state message. Update the DevicesView XAML
templates for the InputDevices and OutputDevices ItemsControl blocks to display
a zero-count hint when the bound collection is empty, using the same
style/pattern as the existing “nothing found” sections so operators can
distinguish no devices from a binding issue.

In `@Resgrid.Relay.Engine/Configuration/RelayConfiguration.cs`:
- Around line 90-91: The user config write in RelayConfiguration should be made
atomic instead of calling File.WriteAllText() directly. Update the save logic
around the JsonSerializer.Serialize flow to write the JSON to a temp file in the
same directory first, flush/close it, then replace or move it into
UserConfigPath only after the write succeeds so relay.user.json is never left
partially written.
- Around line 81-91: `RelayConfiguration.Save` is writing the merged
`RelayHostOptions` returned by `Load()` directly to `relay.user.json`, which can
persist `RESGRID__RELAY__*` overrides as if they were user settings. Update
`Save(RelayHostOptions options)` to serialize only the user-owned config model,
not the effective merged options, and use the existing
`RelayConfiguration`/`RelayHostOptions` flow to strip out env-backed values
before calling `JsonSerializer.Serialize`.

In `@Resgrid.Relay.Engine/Services/RelayServiceBase.cs`:
- Around line 63-76: `StopAsync()` can miss a stop request while `StartAsync()`
is in the `Starting` state because `_cts` is assigned too late. In
`RelayServiceBase.StartAsync`, create and store the linked
`CancellationTokenSource` inside the same `_sync` lock where `_state` is
transitioned to `Starting`, and in `RelayServiceBase.StopAsync` cancel the
captured `_cts` while holding the same lock before returning. Keep the state
change and CTS publication atomic around `StartAsync`, `StopAsync`, and the
`_cts` field so a stop cannot slip through the startup window.

In `@Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs`:
- Around line 57-59: `DispatchVoiceMode` is leaving `MutableStatus.Tts` stuck in
`ConnectionState.Connecting` during startup, which should be treated as unprobed
instead. Update the startup/status initialization path in `DispatchVoiceMode` so
TTS remains `ConnectionState.Unknown` until the first successful synthesis
confirms connectivity, and only transition to `Connected` after a real
announcement succeeds.

---

Nitpick comments:
In `@Resgrid.Audio.Relay/App.xaml.cs`:
- Around line 14-30: Mark the WPF shell assembly as Windows-only by adding a
SupportedOSPlatform annotation at assembly scope so CA1416 stops flagging
Windows-specific APIs. Apply it once for the Resgrid.Audio.Relay assembly, near
the App/Application entry point area, so calls like ApplicationThemeManager,
NavigationView, and TaskbarIcon are recognized as Windows-only without needing
per-call suppressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad0d21f6-aaf4-464a-b4c9-74780008bcf9

📥 Commits

Reviewing files that changed from the base of the PR and between 4b98065 and e475386.

📒 Files selected for processing (75)
  • Directory.Build.props
  • Resgrid.Audio.Core/AudioEvaluator.cs
  • Resgrid.Audio.Core/Radio/RadioBridge.cs
  • Resgrid.Audio.Core/WatcherAudioStorage.cs
  • Resgrid.Audio.Relay/App.xaml
  • Resgrid.Audio.Relay/App.xaml.cs
  • Resgrid.Audio.Relay/Controls/LevelMeter.xaml
  • Resgrid.Audio.Relay/Controls/LevelMeter.xaml.cs
  • Resgrid.Audio.Relay/Controls/Sparkline.cs
  • Resgrid.Audio.Relay/Controls/StatusPill.xaml
  • Resgrid.Audio.Relay/Controls/StatusPill.xaml.cs
  • Resgrid.Audio.Relay/Converters/BoolToBrushConverter.cs
  • Resgrid.Audio.Relay/Converters/BoolToVisibilityConverter.cs
  • Resgrid.Audio.Relay/Converters/BoolToVisibilityInvertedConverter.cs
  • Resgrid.Audio.Relay/Converters/ConnectionStateToBrushConverter.cs
  • Resgrid.Audio.Relay/Converters/CountToVisibilityConverter.cs
  • Resgrid.Audio.Relay/Converters/LogLevelToBrushConverter.cs
  • Resgrid.Audio.Relay/Converters/RelayServiceStateToBrushConverter.cs
  • Resgrid.Audio.Relay/Converters/SecretMaskConverter.cs
  • Resgrid.Audio.Relay/Converters/SquelchTextConverter.cs
  • Resgrid.Audio.Relay/Converters/StatusBrushes.cs
  • Resgrid.Audio.Relay/Converters/TuneButtonTextConverter.cs
  • Resgrid.Audio.Relay/MainWindow.xaml
  • Resgrid.Audio.Relay/MainWindow.xaml.cs
  • Resgrid.Audio.Relay/Properties/Resources.Designer.cs
  • Resgrid.Audio.Relay/Properties/Resources.resx
  • Resgrid.Audio.Relay/Properties/Settings.Designer.cs
  • Resgrid.Audio.Relay/Properties/Settings.settings
  • Resgrid.Audio.Relay/Resgrid.Audio.Relay.csproj
  • Resgrid.Audio.Relay/Resources/AppDictionary.xaml
  • Resgrid.Audio.Relay/Services/ConfigurationService.cs
  • Resgrid.Audio.Relay/Services/DeviceEnumerationService.cs
  • Resgrid.Audio.Relay/Services/RelayController.cs
  • Resgrid.Audio.Relay/ShellWindow.xaml
  • Resgrid.Audio.Relay/ShellWindow.xaml.cs
  • Resgrid.Audio.Relay/Skins/MainSkin.xaml
  • Resgrid.Audio.Relay/ViewModel/MainWindowViewModel.cs
  • Resgrid.Audio.Relay/ViewModel/ShuttingDownMessage.cs
  • Resgrid.Audio.Relay/ViewModel/ViewModelLocator.cs
  • Resgrid.Audio.Relay/ViewModels/AboutViewModel.cs
  • Resgrid.Audio.Relay/ViewModels/ConfigurationViewModel.cs
  • Resgrid.Audio.Relay/ViewModels/DashboardViewModel.cs
  • Resgrid.Audio.Relay/ViewModels/DevicesViewModel.cs
  • Resgrid.Audio.Relay/ViewModels/LogsViewModel.cs
  • Resgrid.Audio.Relay/ViewModels/ModeCardViewModel.cs
  • Resgrid.Audio.Relay/ViewModels/OperationsViewModel.cs
  • Resgrid.Audio.Relay/ViewModels/ServiceTileViewModel.cs
  • Resgrid.Audio.Relay/ViewModels/ShellViewModel.cs
  • Resgrid.Audio.Relay/Views/AboutView.xaml
  • Resgrid.Audio.Relay/Views/AboutView.xaml.cs
  • Resgrid.Audio.Relay/Views/ConfigurationView.xaml
  • Resgrid.Audio.Relay/Views/ConfigurationView.xaml.cs
  • Resgrid.Audio.Relay/Views/DashboardView.xaml
  • Resgrid.Audio.Relay/Views/DashboardView.xaml.cs
  • Resgrid.Audio.Relay/Views/DevicesView.xaml
  • Resgrid.Audio.Relay/Views/DevicesView.xaml.cs
  • Resgrid.Audio.Relay/Views/LogsView.xaml
  • Resgrid.Audio.Relay/Views/LogsView.xaml.cs
  • Resgrid.Audio.Relay/Views/OperationsView.xaml
  • Resgrid.Audio.Relay/Views/OperationsView.xaml.cs
  • Resgrid.Relay.Engine/Configuration/RelayConfiguration.cs
  • Resgrid.Relay.Engine/Logging/LogRecord.cs
  • Resgrid.Relay.Engine/Logging/UiBusSinkExtensions.cs
  • Resgrid.Relay.Engine/Logging/UiLogBus.cs
  • Resgrid.Relay.Engine/Logging/UiLogSink.cs
  • Resgrid.Relay.Engine/Services/AudioImportService.cs
  • Resgrid.Relay.Engine/Services/DispatchRelayService.cs
  • Resgrid.Relay.Engine/Services/RadioRelayService.cs
  • Resgrid.Relay.Engine/Services/RecordRelayService.cs
  • Resgrid.Relay.Engine/Services/RelayServiceBase.cs
  • Resgrid.Relay.Engine/Services/RelayServiceFactory.cs
  • Resgrid.Relay.Engine/Services/SmtpRelayService.cs
  • Resgrid.Relay.Engine/Voice/AudioImportMode.cs
  • Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs
  • Resgrid.Relay.Engine/Voice/RadioMode.cs
💤 Files with no reviewable changes (11)
  • Resgrid.Audio.Relay/ViewModel/ShuttingDownMessage.cs
  • Resgrid.Audio.Relay/Properties/Resources.Designer.cs
  • Resgrid.Audio.Relay/MainWindow.xaml.cs
  • Resgrid.Audio.Relay/Properties/Settings.settings
  • Resgrid.Audio.Relay/MainWindow.xaml
  • Resgrid.Audio.Relay/Properties/Resources.resx
  • Resgrid.Audio.Relay/ViewModel/MainWindowViewModel.cs
  • Resgrid.Audio.Relay/Skins/MainSkin.xaml
  • Resgrid.Audio.Relay/Resources/AppDictionary.xaml
  • Resgrid.Audio.Relay/Properties/Settings.Designer.cs
  • Resgrid.Audio.Relay/ViewModel/ViewModelLocator.cs

Comment on lines +121 to +143
protected override async void OnExit(ExitEventArgs e)
{
try
{
if (_services != null)
{
var controller = _services.GetService<RelayController>();
if (controller != null)
await controller.StopAllAsync().ConfigureAwait(true);

_services.Dispose();
}
}
catch
{
// Best-effort shutdown — never block process exit.
}
finally
{
Log.CloseAndFlush();
_singleInstanceMutex?.Dispose();
base.OnExit(e);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

python3 - <<'PY'
# We need to simulate or find documentation on WPF Application.OnExit behavior.
# Since we can't run the actual app in this sandbox to catch edge cases, 
# we rely on authoritative .NET documentation regarding the Application.Exit event / OnExit override.
# Key question: Does returning from OnExit allow the app to exit immediately, ignoring pending async tasks?

print("Checking WPF OnExit async void behavior...")
print("Documentation indicates that Application.OnExit is called when the application is about to be shut down.")
print("However, OnExit is not an async method. If implemented as async void, the framework does not await it.")
print("If the app continues execution after OnExit returns, the async continuation might not complete.")
print("Crucially, does the app exit immediately after OnExit returns, or does it wait?")
print("Standard behavior: The app shuts down after the handler returns. It does not automatically wait for async tasks.")
print("Therefore, the 'Quit' click sequence: Click -> Shutdown() -> OnExit triggers. OnExit returns immediately after awaiting starts?")
print("Actually, for async void, the 'await' yields, and the method returns. If the app exits, the process ends.")
print("The continuation after 'await' (Dispose, Log.Close) might not run.")
print("Conclusion: The original review comment is likely correct. Async void in critical lifecycle events like OnExit is dangerous because the runtime does not synchronize waiting for the task completion.")

# This is a reasoning check, not a code execution result, but confirms the logic.
print("Analysis Complete")
PY

Repository: Resgrid/Relay

Length of output: 1208


🏁 Script executed:

grep -r "Shutdown" Resgrid.Audio.Relay/App.xaml.cs || echo "Checking App.xaml.cs context..."

Repository: Resgrid/Relay

Length of output: 167


🏁 Script executed:

# Let's look at the file content around the OnExit method to see if there is a Shutdown call or if it relies on the OS/Message loop
cat -n Resgrid.Audio.Relay/App.xaml.cs | grep -A 20 -B 5 "OnExit"

Repository: Resgrid/Relay

Length of output: 940


🏁 Script executed:

# Check if there is a quit command that calls Shutdown explicitly vs just letting the window close
# We need to see the OnMainWindowClosed or TriggerQuit logic if it exists.
ast-grep run --pattern 'Application.Current.Shutdown($$$)' --lang csharp Resgrid.Audio.Relay/

Repository: Resgrid/Relay

Length of output: 230


🏁 Script executed:

python3 - <<'PY'
# Logic Check
# 1. ShellWindow.xaml.cs:113 calls Application.Current.Shutdown().
# 2. Shutdown() fires the Exit event (OnExit) and then terminates.
# 3. The base class (Application) expects OnExit to return synchronously.
# 4. async void OnExit starts, awaits controller.StopAllAsync().
# 5. This 'await' yields control.
# 6. Does base class OnExit handling respect the await? No. It usually treats it as a background task if not awaited.
# 7. Does the thread terminate immediately after Shutdown() starts?
#    - For a WPF app, the message loop ends when Shutdown() processes the exit.
#    - If OnExit returns (yields) before the await completes, the framework might consider the handler finished.
#    - However, in WPF, the Application.Exit event handler is expected to complete before the app fully exits *if* it was synchronous.
#    - With async void, the framework doesn't know to wait.
#    - Result: The application process might exit while the task is suspended, meaning the 'finally' block (Line 138-143) never runs.

print("Verdict: The concern is valid.")
print("The 'finally' block contains critical cleanup (Log, Mutex).")
print("If the app exits while awaiting, the 'finally' block is skipped, and the process is terminated.")
print("This leads to potential log truncation and mutex leaks.")
PY

Repository: Resgrid/Relay

Length of output: 394


🏁 Script executed:

# Check if there is a safe way to exit or if we can just rely on the OS.
# But the comment specifically claims "quit" path is at risk.
# Verify if Shutdown() is the only way to quit or if there are other triggers.
grep -n "Exit" Resgrid.Audio.Relay/App.xaml.cs

Repository: Resgrid/Relay

Length of output: 235


🏁 Script executed:

# Confirm if ShellWindow is the main window and how it handles close vs shutdown
# looking at the file around line 113
sed -n '105,120p' Resgrid.Audio.Relay/ShellWindow.xaml.cs

Repository: Resgrid/Relay

Length of output: 630


Asynchronous OnExit logic risks skipping cleanup

Application.OnExit is not awaited by the WPF runtime. When Application.Current.Shutdown() is called, it invokes this method and proceeds to terminate the process immediately upon the method's return (yielding at await controller.StopAllAsync()). This causes the finally block to be skipped, leaving Serilog logs unflushed and the single-instance mutex unreleased.

Move the StopAllAsync logic into the explicit quit command in ShellWindow.xaml.cs (calling it before Shutdown()), and revert OnExit to be strictly synchronous with only cleanup tasks.

Original Concerned Code
protected override async void OnExit(ExitEventArgs e)
{
	try
	{
		if (_services != null)
		{
			var controller = _services.GetService<RelayController>();
			if (controller != null)
				await controller.StopAllAsync().ConfigureAwait(true);

			_services.Dispose();
		}
	}
	catch
	{
		// Best-effort shutdown — never block process exit.
	}
	finally
	{
		Log.CloseAndFlush();
		_singleInstanceMutex?.Dispose();
		base.OnExit(e);
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Relay/App.xaml.cs` around lines 121 - 143, `App.OnExit` is
doing asynchronous work in an `async void` override, which can return before
cleanup runs and skip the `finally` block. Move the
`RelayController.StopAllAsync` shutdown sequence out of `OnExit` and into the
explicit quit flow in `ShellWindow` before `Application.Current.Shutdown()`,
then make `OnExit` synchronous so it only handles disposal and final cleanup
like `Log.CloseAndFlush` and `_singleInstanceMutex.Dispose`.

Comment on lines +64 to +122
public static readonly DependencyProperty ValuesProperty = DependencyProperty.Register(
nameof(Values),
typeof(IEnumerable<double>),
typeof(Sparkline),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnValuesChanged));

/// <summary>The series to plot. Min/max are auto-scaled to the control height.</summary>
public IEnumerable<double> Values
{
get => (IEnumerable<double>)GetValue(ValuesProperty);
set => SetValue(ValuesProperty, value);
}

public static readonly DependencyProperty StrokeProperty = DependencyProperty.Register(
nameof(Stroke),
typeof(Brush),
typeof(Sparkline),
new FrameworkPropertyMetadata(Brushes.LimeGreen, OnStrokeChanged));

public Brush Stroke
{
get => (Brush)GetValue(StrokeProperty);
set => SetValue(StrokeProperty, value);
}

private static void OnValuesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((Sparkline)d).Redraw();
}

private static void OnStrokeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((Sparkline)d)._polyline.Stroke = e.NewValue as Brush ?? Brushes.LimeGreen;
}

private void Redraw()
{
var width = ActualWidth;
var height = ActualHeight;
_polyline.Points.Clear();

var data = Values?.ToList();
if (data == null || data.Count < 2 || width <= 0 || height <= 0)
return;

var min = data.Min();
var max = data.Max();
var range = max - min;
if (range <= 0)
range = 1;

var stepX = width / (data.Count - 1);
for (var i = 0; i < data.Count; i++)
{
var x = i * stepX;
var y = height - ((data[i] - min) / range * height);
_polyline.Points.Add(new Point(x, y));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Redraw on collection changes, not just reassignment.

Values only redraws when the dependency property changes. If callers mutate a bound ObservableCollection<double> in place, the sparkline will stay stale. Subscribe to INotifyCollectionChanged (and detach from the old source) or require the sequence to be replaced on each update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Relay/Controls/Sparkline.cs` around lines 64 - 122, The
Sparkline control only redraws when Values is reassigned, so in-place updates to
an ObservableCollection<double> are missed. Update Sparkline to handle
INotifyCollectionChanged in ValuesProperty/OnValuesChanged by subscribing to the
new collection and detaching from the old one, then calling Redraw when the
collection changes; keep Redraw as the rendering entry point and preserve the
existing OnValuesChanged behavior for initial assignment.

Comment on lines +17 to +30
public bool Invert { get; set; }

public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var flag = value is bool b && b;
if (Invert)
flag = !flag;
return flag ? Visibility.Visible : Visibility.Collapsed;
}

public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is Visibility v && v == Visibility.Visible;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Honor Invert in ConvertBack too.

Convert flips the mapping when Invert is set, but ConvertBack always treats Visible as true. Any two-way binding that uses the inverted mode will write the wrong boolean back to the source.

🔧 Proposed fix
 		public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
 		{
-			return value is Visibility v && v == Visibility.Visible;
+			var flag = value is Visibility v && v == Visibility.Visible;
+			return Invert ? !flag : flag;
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public bool Invert { get; set; }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var flag = value is bool b && b;
if (Invert)
flag = !flag;
return flag ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is Visibility v && v == Visibility.Visible;
}
public bool Invert { get; set; }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var flag = value is bool b && b;
if (Invert)
flag = !flag;
return flag ? Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
var flag = value is Visibility v && v == Visibility.Visible;
return Invert ? !flag : flag;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Relay/Converters/BoolToVisibilityConverter.cs` around lines 17
- 30, BoolToVisibilityConverter only applies Invert in Convert, so ConvertBack
still returns the non-inverted boolean and breaks two-way bindings. Update
ConvertBack to mirror the same inversion logic used in Convert, using the Invert
property to flip the Visibility.Visible/Collapsed mapping before returning the
bool result.

Comment on lines +16 to +21
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var s = value as string;
if (string.IsNullOrEmpty(s))
return "";
return new string('•', Math.Min(Math.Max(s.Length, 8), 16));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Use a constant mask length for stored secrets.

Line 21 still exposes the secret's approximate length. For masked config fields, any non-empty value should render the same bullet count.

Suggested fix
 		public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
 		{
 			var s = value as string;
 			if (string.IsNullOrEmpty(s))
 				return "";
-			return new string('•', Math.Min(Math.Max(s.Length, 8), 16));
+			return "••••••••";
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var s = value as string;
if (string.IsNullOrEmpty(s))
return "";
return new string('•', Math.Min(Math.Max(s.Length, 8), 16));
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var s = value as string;
if (string.IsNullOrEmpty(s))
return "";
return "••••••••";
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Relay/Converters/SecretMaskConverter.cs` around lines 16 - 21,
The SecretMaskConverter.Convert method is still leaking approximate secret
length by varying the mask based on the input size; update it so any non-empty
string always returns the same fixed bullet mask length, while keeping the
empty/null behavior unchanged. Use the existing SecretMaskConverter symbol to
locate the change and remove the Math.Min/Math.Max length-based logic so stored
secrets render identically regardless of actual value length.

Comment on lines +130 to +159
RunningEntry entry;
lock (_gate)
{
if (!_entries.TryGetValue(service, out entry))
return;
_entries.Remove(service);
}

service.StateChanged -= OnServiceStateChanged;

try
{
entry.Cts.Cancel();
try { await service.StopAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger?.Warning(ex, "Error stopping relay mode '{Mode}'.", service.Mode); }

if (entry.RunTask != null)
{
try { await entry.RunTask.ConfigureAwait(false); }
catch { /* already logged in run loop */ }
}
}
finally
{
try { await service.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger?.Warning(ex, "Error disposing relay mode '{Mode}'.", service.Mode); }

entry.Cts.Dispose();
Post(() => RunningServices.Remove(service));
RaiseOverallStateChanged();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Keep the service tracked until shutdown actually finishes.

Removing the entry before Cancel/StopAsync/RunTask completion makes IsRunning and OverallState go idle while the relay task is still alive. That reopens Start() early and can overlap a new service with the still-stopping one on the same resources. Remove _entries[service] in finally, after the run task has completed, or add an explicit stopping flag instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Relay/Services/RelayController.cs` around lines 130 - 159, The
shutdown flow in RelayController should keep the service tracked until
stop/cleanup fully completes, because removing it from _entries before Cancel,
StopAsync, and RunTask finish lets IsRunning and OverallState go idle too early.
Update the stop path around the service entry handling in RelayController so the
entry is only removed in the finally block after await service.StopAsync and
awaiting entry.RunTask, or introduce an explicit stopping state in the entry to
prevent Start from reopening while the relay is still shutting down.

Comment on lines +33 to +58
<ui:Card Padding="16">
<StackPanel>
<TextBlock Text="Audio Input (radio receive)" FontWeight="SemiBold" Margin="0,0,0,8" />
<ItemsControl ItemsSource="{Binding InputDevices}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Display}" Margin="0,2" FontFamily="Consolas" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ui:Card>

<!-- Audio output devices -->
<ui:Card Padding="16" Margin="0,12,0,0">
<StackPanel>
<TextBlock Text="Audio Output (radio transmit)" FontWeight="SemiBold" Margin="0,0,0,8" />
<ItemsControl ItemsSource="{Binding OutputDevices}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Display}" Margin="0,2" FontFamily="Consolas" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ui:Card>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show an empty state for the audio device cards.

When InputDevices or OutputDevices is empty, these sections render as blank cards while the serial/HID sections explicitly say nothing was found. Add the same zero-count hint here so operators can distinguish “no devices” from a broken binding.

Suggested change
                 <ui:Card Padding="16">
                     <StackPanel>
                         <TextBlock Text="Audio Input (radio receive)" FontWeight="SemiBold" Margin="0,0,0,8" />
                         <ItemsControl ItemsSource="{Binding InputDevices}">
                             <ItemsControl.ItemTemplate>
                                 <DataTemplate>
                                     <TextBlock Text="{Binding Display}" Margin="0,2" FontFamily="Consolas" />
                                 </DataTemplate>
                             </ItemsControl.ItemTemplate>
                         </ItemsControl>
+                        <TextBlock Text="No audio input devices found."
+                                   Opacity="0.5" FontStyle="Italic"
+                                   Visibility="{Binding InputDevices.Count, Converter={x:Static conv:CountToVisibilityConverter.ZeroVisible}}" />
                     </StackPanel>
                 </ui:Card>
@@
                 <ui:Card Padding="16" Margin="0,12,0,0">
                     <StackPanel>
                         <TextBlock Text="Audio Output (radio transmit)" FontWeight="SemiBold" Margin="0,0,0,8" />
                         <ItemsControl ItemsSource="{Binding OutputDevices}">
                             <ItemsControl.ItemTemplate>
                                 <DataTemplate>
                                     <TextBlock Text="{Binding Display}" Margin="0,2" FontFamily="Consolas" />
                                 </DataTemplate>
                             </ItemsControl.ItemTemplate>
                         </ItemsControl>
+                        <TextBlock Text="No audio output devices found."
+                                   Opacity="0.5" FontStyle="Italic"
+                                   Visibility="{Binding OutputDevices.Count, Converter={x:Static conv:CountToVisibilityConverter.ZeroVisible}}" />
                     </StackPanel>
                 </ui:Card>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<ui:Card Padding="16">
<StackPanel>
<TextBlock Text="Audio Input (radio receive)" FontWeight="SemiBold" Margin="0,0,0,8" />
<ItemsControl ItemsSource="{Binding InputDevices}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Display}" Margin="0,2" FontFamily="Consolas" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ui:Card>
<!-- Audio output devices -->
<ui:Card Padding="16" Margin="0,12,0,0">
<StackPanel>
<TextBlock Text="Audio Output (radio transmit)" FontWeight="SemiBold" Margin="0,0,0,8" />
<ItemsControl ItemsSource="{Binding OutputDevices}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Display}" Margin="0,2" FontFamily="Consolas" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ui:Card>
<ui:Card Padding="16">
<StackPanel>
<TextBlock Text="Audio Input (radio receive)" FontWeight="SemiBold" Margin="0,0,0,8" />
<ItemsControl ItemsSource="{Binding InputDevices}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Display}" Margin="0,2" FontFamily="Consolas" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="No audio input devices found."
Opacity="0.5" FontStyle="Italic"
Visibility="{Binding InputDevices.Count, Converter={x:Static conv:CountToVisibilityConverter.ZeroVisible}}" />
</StackPanel>
</ui:Card>
<!-- Audio output devices -->
<ui:Card Padding="16" Margin="0,12,0,0">
<StackPanel>
<TextBlock Text="Audio Output (radio transmit)" FontWeight="SemiBold" Margin="0,0,0,8" />
<ItemsControl ItemsSource="{Binding OutputDevices}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Display}" Margin="0,2" FontFamily="Consolas" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="No audio output devices found."
Opacity="0.5" FontStyle="Italic"
Visibility="{Binding OutputDevices.Count, Converter={x:Static conv:CountToVisibilityConverter.ZeroVisible}}" />
</StackPanel>
</ui:Card>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Audio.Relay/Views/DevicesView.xaml` around lines 33 - 58, The audio
device cards currently render as blank panels when InputDevices or OutputDevices
has no items, unlike the serial and HID sections that show a clear empty-state
message. Update the DevicesView XAML templates for the InputDevices and
OutputDevices ItemsControl blocks to display a zero-count hint when the bound
collection is empty, using the same style/pattern as the existing “nothing
found” sections so operators can distinguish no devices from a binding issue.

Comment on lines +81 to +91
public static void Save(RelayHostOptions options)
{
if (options == null)
throw new ArgumentNullException(nameof(options));

var directory = Path.GetDirectoryName(UserConfigPath);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);

var json = JsonSerializer.Serialize(options, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(UserConfigPath, json);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Save() persists environment overrides back into relay.user.json.

Load() binds the env layer straight into RelayHostOptions, and Save() serializes that merged object unchanged. If the UI saves while RESGRID__RELAY__* is set, those env-backed values become permanently stored in relay.user.json and will keep winning after the env vars are removed. Persist a user-config-only model instead of the effective merged options.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Relay.Engine/Configuration/RelayConfiguration.cs` around lines 81 -
91, `RelayConfiguration.Save` is writing the merged `RelayHostOptions` returned
by `Load()` directly to `relay.user.json`, which can persist `RESGRID__RELAY__*`
overrides as if they were user settings. Update `Save(RelayHostOptions options)`
to serialize only the user-owned config model, not the effective merged options,
and use the existing `RelayConfiguration`/`RelayHostOptions` flow to strip out
env-backed values before calling `JsonSerializer.Serialize`.

Comment on lines +90 to +91
var json = JsonSerializer.Serialize(options, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(UserConfigPath, json);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Write the user config atomically.

File.WriteAllText() can leave relay.user.json truncated or invalid if the process exits mid-write. Use a temp file in the same directory and replace/move it after the write succeeds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Relay.Engine/Configuration/RelayConfiguration.cs` around lines 90 -
91, The user config write in RelayConfiguration should be made atomic instead of
calling File.WriteAllText() directly. Update the save logic around the
JsonSerializer.Serialize flow to write the JSON to a temp file in the same
directory first, flush/close it, then replace or move it into UserConfigPath
only after the write succeeds so relay.user.json is never left partially
written.

Comment on lines +63 to 76
lock (_sync)
{
if (_state == RelayServiceState.Starting || _state == RelayServiceState.Running || _state == RelayServiceState.Stopping)
throw new InvalidOperationException($"The '{Mode}' relay service is already active (state: {_state}).");
previous = _state;
_state = RelayServiceState.Starting;
}
StateChanged?.Invoke(this, new RelayStateChangedEventArgs(previous, RelayServiceState.Starting));

_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
TransitionTo(RelayServiceState.Starting);
try
{
TransitionTo(RelayServiceState.Running);
await ExecuteAsync(_cts.Token).ConfigureAwait(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

StopAsync() can miss cancellation during the new Starting window.

_state is published as Starting before _cts exists. If StopAsync() runs in that gap, it moves the service to Stopping, sees _cts == null, and returns; StartAsync() then creates the CTS and enters ExecuteAsync, so the relay keeps running after a stop request. Create/store the linked CTS inside the same critical section as the state transition, and have StopAsync() cancel that captured instance under the same lock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Relay.Engine/Services/RelayServiceBase.cs` around lines 63 - 76,
`StopAsync()` can miss a stop request while `StartAsync()` is in the `Starting`
state because `_cts` is assigned too late. In `RelayServiceBase.StartAsync`,
create and store the linked `CancellationTokenSource` inside the same `_sync`
lock where `_state` is transitioned to `Starting`, and in
`RelayServiceBase.StopAsync` cancel the captured `_cts` while holding the same
lock before returning. Keep the state change and CTS publication atomic around
`StartAsync`, `StopAsync`, and the `_cts` field so a stop cannot slip through
the startup window.

Comment on lines +57 to +59
// TTS reachability is unverified until the first synthesis actually reaches the service,
// so leave status.Tts as the caller set it (Connecting) and confirm Connected on the first
// successful announcement below.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Don't leave TTS in Connecting after startup.

DispatchRelayService initializes MutableStatus.Tts to ConnectionState.Connecting, so preserving that value here makes an idle dispatch service — or one that only sees failed announcements — look permanently mid-connect. ConnectionState.Unknown already models “applicable but unprobed”, which is the state you actually have until the first synthesis succeeds.

Suggested fix
-			// TTS reachability is unverified until the first synthesis actually reaches the service,
-			// so leave status.Tts as the caller set it (Connecting) and confirm Connected on the first
-			// successful announcement below.
+			// TTS reachability is unverified until the first synthesis actually reaches the service,
+			// so report it as applicable-but-unprobed here and confirm Connected on the first
+			// successful announcement below.
+			if (status != null)
+				status.Tts = ConnectionState.Unknown;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// TTS reachability is unverified until the first synthesis actually reaches the service,
// so leave status.Tts as the caller set it (Connecting) and confirm Connected on the first
// successful announcement below.
// TTS reachability is unverified until the first synthesis actually reaches the service,
// so report it as applicable-but-unprobed here and confirm Connected on the first
// successful announcement below.
if (status != null)
status.Tts = ConnectionState.Unknown;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resgrid.Relay.Engine/Voice/DispatchVoiceMode.cs` around lines 57 - 59,
`DispatchVoiceMode` is leaving `MutableStatus.Tts` stuck in
`ConnectionState.Connecting` during startup, which should be treated as unprobed
instead. Update the startup/status initialization path in `DispatchVoiceMode` so
TTS remains `ConnectionState.Unknown` until the first successful synthesis
confirms connectivity, and only transition to `Connected` after a real
announcement succeeds.

@ucswift ucswift closed this Jun 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant