Skip to content

fix: drop async-void pipe listener and sync-over-async wrappers#616

Merged
laurentiu021 merged 1 commit into
mainfrom
fix/p1-high-async-and-resource-fixes
Jun 3, 2026
Merged

fix: drop async-void pipe listener and sync-over-async wrappers#616
laurentiu021 merged 1 commit into
mainfrom
fix/p1-high-async-and-resource-fixes

Conversation

@laurentiu021
Copy link
Copy Markdown
Owner

@laurentiu021 laurentiu021 commented Jun 3, 2026

Summary

Round 3 of the post-1.18.0 audit fixes. Tackles three high-priority async correctness issues left over after PR #614.

What's fixed

App.xaml.cs — StartPipeListener was async void

Before: private async void StartPipeListener(). Any exception escaping the loop (rare but possible: corrupted pipe state, OOM during cancellation) would propagate to the AppDomain unhandled-exception handler and crash the app instead of going through the normal task-exception path.
After: renamed to StartPipeListenerAsync() returning Task. OnStartup invokes it as _ = StartPipeListenerAsync(). Escaped exceptions now surface via TaskScheduler.UnobservedTaskException, which App.OnTask already logs cleanly. Cancellation behavior is unchanged (_pipeCts is still disposed in OnExit).

StartupService — dropped sync-over-async wrapper

Before: public static bool SetEnabled(StartupEntry, bool) => SetEnabledAsync(...).GetAwaiter().GetResult(); — only used by tests. The pattern is brittle: any blocking dependency in SetEnabledAsync (e.g., a future SemaphoreSlim) could deadlock when called from a sync context.
After: wrapper deleted. StartupToggleTests now uses async Task test methods that await SetEnabledAsync directly — the canonical xUnit async-test pattern.

StartupService.SetTaskSchedulerEnabledAsync — async stderr read with timeout

Before:

var stderr = stderrTask.Wait(TimeSpan.FromSeconds(5))
    ? stderrTask.GetAwaiter().GetResult().Trim()
    : string.Empty;

Sync Task.Wait(timeout) blocks the current thread; .GetAwaiter().GetResult() rethrows synchronously. Inside an already-async method, this is the wrong pattern.
After:

try
{
    stderr = (await stderrTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false)).Trim();
}
catch (TimeoutException) { stderr = string.Empty; }

Pure async, with a clean TimeoutException fallback.

Files

  • App.xaml.cs — rename + signature change + caller update
  • Services/StartupService.cs — drop sync wrapper, switch to WaitAsync
  • SysManager.Tests/StartupToggleTests.cs — migrate 5 tests to async Task
  • CHANGELOG.md[1.18.2] entry
  • SysManager.csproj — bump to 1.18.2

Audit findings deliberately skipped (verified false alarms)

These were flagged by audit agents but checked by hand and rejected:

  • H1 — ManagementScope using: the type does not implement IDisposable in .NET 10 (verified by attempting using var scope = new ManagementScope(...) — produces CS1674). Nothing to dispose.
  • H2 — ThemeService.Initialize async: must run synchronously before the first window is shown to avoid a flash of default theme. The settings file is ~200 bytes; the read is sub-millisecond.
  • H3 — ActivityLogService ctor async: singleton uses lazy initialization; Load() runs on first access from a non-UI context. JSON file is small (max 20 entries). Not a UI freeze risk.
  • H4 — LogService [GeneratedRegex]: the regex is built at runtime from Environment.GetFolderPath(UserProfile) so a source generator cannot help. The fallback regex is rarely hit.
  • H5 — ProcessManagerService.Thread.Sleep(100): invoked inside a sync Snapshot() method that callers wrap in Task.Run(...). Runs on a thread-pool thread, never on the UI thread.
  • H6 — Dashboard Task.Run(async () => ...): the wrapped lambda calls UpdateGpuUsage() (a sync NvAPI call); without Task.Run, the first synchronous portion would run on the UI context. The wrapper is required.
  • H10 — WindowsUpdateService dynamic COM: working code touching the WUA COM API; Marshal.FinalReleaseComObject is paired correctly. The dynamic is intentional (no typed interop assembly available). Risk of breakage during refactor outweighs the maintainability win.

Test plan

  • dotnet build Release: 0 warnings, 0 errors (main, Tests, UITests)
  • CI build + unit tests
  • CI UI tests
  • CodeQL clean
  • Smoke test on local Windows after release: app launches, single-instance pipe still activates existing instance, Startup toggle still works (registry + Task Scheduler paths)

Summary by CodeRabbit

  • Bug Fixes

    • Improved exception handling and async execution for startup initialization.
    • Enhanced Task Scheduler operation error handling with better timeout management.
    • Refined stderr processing for system task operations.
  • Chores

    • Version bumped to 1.18.2.

- App.xaml.cs: rename StartPipeListener (async void) to StartPipeListenerAsync (Task); fire as _ = StartPipeListenerAsync() so escaped exceptions surface via UnobservedTaskException instead of crashing the AppDomain
- StartupService: drop the SetEnabled(...) sync wrapper that called .GetAwaiter().GetResult() on SetEnabledAsync; tests migrated to call SetEnabledAsync directly
- StartupService.SetTaskSchedulerEnabledAsync: replace stderrTask.Wait(timeout) + GetAwaiter().GetResult() with stderrTask.WaitAsync(timeout) for a clean async + TimeoutException fallback
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR refactors async patterns across the SystemManager codebase by removing synchronous wrappers, converting async void anti-patterns to async Task, and making error handling fully asynchronous with timeouts. Version is bumped to 1.18.2.

Changes

Async Pattern Enforcement

Layer / File(s) Summary
Startup toggle async enforcement
SysManager/SysManager/Services/StartupService.cs
The public sync wrapper SetEnabled() is removed; callers must use SetEnabledAsync(). Stderr collection for schtasks.exe is changed from blocking Wait() to async WaitAsync(5s) with timeout fallback to empty string.
Pipe listener async Task refactoring
SysManager/SysManager/App.xaml.cs
StartPipeListenerAsync() now returns Task instead of async void, allowing the startup caller to fire-and-forget without the anti-pattern, and XML documentation is updated to reflect this change.
Test suite migration to async API
SysManager/SysManager.Tests/StartupToggleTests.cs
All five test methods (SetEnabled_TaskScheduler_EmptyPath, SetEnabled_TaskScheduler_NullPath, SetEnabled_RegistryEntry_NeverShowsGenericAdminMessage, SetEnabled_TaskScheduler_WithBogusPath, SetEnabled_PreservesEntryState_OnFailure) are converted from public void to public async Task and updated to await SetEnabledAsync(), with all assertions preserved.
Version bump and release notes
SysManager/SysManager/SysManager.csproj, CHANGELOG.md
Assembly version updated from 1.18.1 to 1.18.2; changelog documents the three fixed items: StartPipeListenerAsync Task return, removal of SetEnabled sync wrapper, and async schtasks stderr timeout.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • laurentiu021/SystemManager#585: Modifies StartupService.cs to migrate startup entry toggling to async and updates Task Scheduler stderr/error handling with timeout-based async waits.
  • laurentiu021/SystemManager#396: Updates StartupService schtasks stderr handling with async timeout-based wait/fallback and converts the named-pipe listener to async Task-based entrypoint.
  • laurentiu021/SystemManager#556: Updates version metadata in SysManager.csproj to new version numbers.

🐰 Async patterns bloom like wildflowers—
No more void fires in the night,
Task returns bring order, timeouts catch the light.
Version bumps with grace, refactoring's delight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main changes: removing an async-void anti-pattern from the pipe listener and eliminating sync-over-async wrappers from StartupService.
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 fix/p1-high-async-and-resource-fixes

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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
SysManager/SysManager.Tests/StartupToggleTests.cs (1)

55-68: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

This test still hits the success path.

SetEnabledAsync does not consult entry.RegistryKey here; for RegistryCurrentUser it opens StartupApproved\Run and writes entry.ValueName directly. With a fresh GUID value name, this call can succeed and set StatusText to "Disabled", so the test never proves the registry-failure message contract implied by its name. Either assert the success behavior explicitly, or move the “no generic admin message on registry failure” check to a test that can force a real registry write failure.

🤖 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 `@SysManager/SysManager.Tests/StartupToggleTests.cs` around lines 55 - 68, The
test SetEnabledAsync_RegistryEntry_NeverShowsGenericAdminMessage currently
succeeds because SetEnabledAsync (called on StartupService) ignores
entry.RegistryKey for RegistryCurrentUser and writes to StartupApproved\Run
using entry.ValueName, so a fresh GUID value can be created and StatusText
becomes "Disabled"; to fix, either (A) change the assertion to explicitly assert
the successful path (e.g., assert StatusText == "Disabled" and no admin message)
when Source == StartupSource.RegistryCurrentUser, or (B) move this "no generic
admin message on registry failure" check into a new test that forces a registry
write failure (for example set Source to RegistryLocalMachine or use an
invalid/non-writable registry hive, or mock the registry accessor used by
StartupService to throw when SetEnabledAsync is called) and then assert that
StatusText and the logged messages meet the failure contract; update the test to
reference StartupEntry, ValueName, RegistryKey,
StartupSource.RegistryCurrentUser, StartupApproved\\Run, SetEnabledAsync, and
StatusText accordingly so it deterministically exercises the intended success or
failure path.
🤖 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 `@SysManager/SysManager/App.xaml.cs`:
- Around line 81-84: StartPipeListenerAsync is started fire-and-forget, so
unhandled exceptions may never be observed; instead either ensure
StartPipeListenerAsync catches and logs all exceptions internally (wrap its
listener loop in try/catch and log before rethrowing or swallowing), or attach
an explicit fault-only continuation at the call site to log exceptions (replace
the "_ = StartPipeListenerAsync();" call with
StartPipeListenerAsync().ContinueWith(...,
TaskContinuationOptions.OnlyOnFaulted) and log t.Exception), while preserving
cancellation via _pipeCts and the existing OnExit behavior.

---

Outside diff comments:
In `@SysManager/SysManager.Tests/StartupToggleTests.cs`:
- Around line 55-68: The test
SetEnabledAsync_RegistryEntry_NeverShowsGenericAdminMessage currently succeeds
because SetEnabledAsync (called on StartupService) ignores entry.RegistryKey for
RegistryCurrentUser and writes to StartupApproved\Run using entry.ValueName, so
a fresh GUID value can be created and StatusText becomes "Disabled"; to fix,
either (A) change the assertion to explicitly assert the successful path (e.g.,
assert StatusText == "Disabled" and no admin message) when Source ==
StartupSource.RegistryCurrentUser, or (B) move this "no generic admin message on
registry failure" check into a new test that forces a registry write failure
(for example set Source to RegistryLocalMachine or use an invalid/non-writable
registry hive, or mock the registry accessor used by StartupService to throw
when SetEnabledAsync is called) and then assert that StatusText and the logged
messages meet the failure contract; update the test to reference StartupEntry,
ValueName, RegistryKey, StartupSource.RegistryCurrentUser, StartupApproved\\Run,
SetEnabledAsync, and StatusText accordingly so it deterministically exercises
the intended success or failure path.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 691a4f93-e20f-4bf1-a53d-471580f5094d

📥 Commits

Reviewing files that changed from the base of the PR and between 9e5826c and 827a474.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • SysManager/SysManager.Tests/StartupToggleTests.cs
  • SysManager/SysManager/App.xaml.cs
  • SysManager/SysManager/Services/StartupService.cs
  • SysManager/SysManager/SysManager.csproj
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Build & unit tests
  • GitHub Check: Analyze (csharp)
🔇 Additional comments (3)
SysManager/SysManager/Services/StartupService.cs (1)

440-448: LGTM!

SysManager/SysManager.Tests/StartupToggleTests.cs (1)

17-33: LGTM!

Also applies to: 36-52, 74-109

SysManager/SysManager/SysManager.csproj (1)

13-15: LGTM!

Comment on lines +81 to +84
// Start listening for activation requests from subsequent instances.
// Fire-and-forget is intentional — the listener loop runs for the app
// lifetime and is cancelled via _pipeCts on OnExit.
_ = StartPipeListenerAsync();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In modern .NET / WPF, when does TaskScheduler.UnobservedTaskException fire for a faulted fire-and-forget Task, and is it guaranteed to run before process exit?

💡 Result:

The TaskScheduler.UnobservedTaskException event is triggered when a faulted Task containing an unobserved exception is garbage collected [1][2]. For a fire-and-forget task, this means the event will only fire after the task has completed with an exception, the task object has become unreachable, and the Garbage Collector (GC) has performed a collection that finalizes the task [1][2]. Crucially, this event is not guaranteed to run before the process exits [3]. Because it relies on the GC, if the application terminates before a garbage collection occurs, the event will never be raised [1][3]. Consequently, you cannot rely on this event as a robust mechanism for logging or handling exceptions in fire-and-forget tasks [1][2]. In modern.NET (including WPF applications), the recommended practice is to explicitly observe all tasks, even those intended as fire-and-forget, by attaching a continuation (e.g., ContinueWith) or using an asynchronous wrapper that handles exceptions, such as a try-catch block inside the task itself [2][4]. Relying on UnobservedTaskException is discouraged because its timing is nondeterministic and dependent on GC behavior [1][5].

Citations:


Don’t rely on TaskScheduler.UnobservedTaskException for pipe-listener failures (SysManager/SysManager/App.xaml.cs lines 81-84).

StartPipeListenerAsync() is started fire-and-forget (_ = ...), so any exception escaping the listener loop is surfaced only when the faulted Task is later garbage-collected; this timing is nondeterministic and may not occur before process exit. Catch/log inside StartPipeListenerAsync or attach an explicit fault-only continuation at the call site (instead of relying on UnobservedTaskException).

💡 One minimal fix
-        _ = StartPipeListenerAsync();
+        _ = StartPipeListenerAsync().ContinueWith(
+            t => LogService.Logger?.Error(t.Exception, "Named-pipe listener failed"),
+            CancellationToken.None,
+            TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+            TaskScheduler.Default);
📝 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
// Start listening for activation requests from subsequent instances.
// Fire-and-forget is intentional — the listener loop runs for the app
// lifetime and is cancelled via _pipeCts on OnExit.
_ = StartPipeListenerAsync();
// Start listening for activation requests from subsequent instances.
// Fire-and-forget is intentional — the listener loop runs for the app
// lifetime and is cancelled via _pipeCts on OnExit.
_ = StartPipeListenerAsync().ContinueWith(
t => LogService.Logger?.Error(t.Exception, "Named-pipe listener failed"),
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
🤖 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 `@SysManager/SysManager/App.xaml.cs` around lines 81 - 84,
StartPipeListenerAsync is started fire-and-forget, so unhandled exceptions may
never be observed; instead either ensure StartPipeListenerAsync catches and logs
all exceptions internally (wrap its listener loop in try/catch and log before
rethrowing or swallowing), or attach an explicit fault-only continuation at the
call site to log exceptions (replace the "_ = StartPipeListenerAsync();" call
with StartPipeListenerAsync().ContinueWith(...,
TaskContinuationOptions.OnlyOnFaulted) and log t.Exception), while preserving
cancellation via _pipeCts and the existing OnExit behavior.

@laurentiu021 laurentiu021 merged commit 755733e into main Jun 3, 2026
5 checks passed
@laurentiu021 laurentiu021 deleted the fix/p1-high-async-and-resource-fixes branch June 3, 2026 14:30
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