Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,13 @@ jobs:
./scripts/provision-winui-test-runtime.ps1
- name: MediaCore, Control, WinUI and bridge tests
run: npm run test:native-shell
- name: WinUI control lifetime regression (no window)
timeout-minutes: 5
run: |
dotnet build native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj -c Release -p:Platform=x64
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& ./native-shell/CoreVideoPro.WinUI.LifetimeTests/bin/x64/Release/net9.0-windows10.0.19041.0/win-x64/CoreVideoPro.WinUI.LifetimeTests.exe
exit $LASTEXITCODE
- name: Upload Windows test results
if: always()
uses: actions/upload-artifact@v4
Expand Down
13 changes: 13 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ TDD + review loop). Estimate for tiers 1-4 ≈ 3-4 working days; tier 5 ≈ 5-8

---

## Current implementation order — owner approved 2026-09-14

The owner approved the ISO recorder work in merged PR #531 and selected this
next sequence: #513 idle XAML crash investigation/hardening; #516 long command
lock hold; #532 RTMP startup recovery; then #456 media In/Out points. This is the
current implementation order ahead of the older ranking below. The In/Out UI
still needs its concrete design reviewed before implementation.

For #513, the first change removes audio-meter control recreation on level and
decay updates and prevents unloaded meters from restarting timers. This reduces
XAML lifetime churn; it does **not** establish the cause or closure of the idle
fail-fast. See [evidence and validation](xaml-lifetime-hardening-2026-09-14.md).

## Tier 0 — owner actions (start the clocks)

**New observations, awaiting owner ranking (2026-09-14):**
Expand Down
63 changes: 63 additions & 0 deletions docs/xaml-lifetime-hardening-2026-09-14.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Audio-meter lifetime hardening — 2026-09-14

Issue: [#513](https://github.com/iamfatness/CoreVideoPro/issues/513).

## Evidence boundary

The original idle crash released a native Border from a WinRT wrapper on the GC
finalizer thread. The [owner's follow-up](https://github.com/iamfatness/CoreVideoPro/issues/513#issuecomment-5652789273)
explicitly retracts the proposed immediate forced-GC reproduction: multiple
collections, Takes, and recording cycles survived, and thousands of wrappers
finalized normally. Finalizer-thread release alone is not a bug. The faulting
object's earlier history and the framework release-order failure remain unknown.

This change is mitigation and regression prevention, not a claim that #513 is
fixed. No framework package versions changed. Other dynamic control factories
remain outside this patch.

## Concrete changes

Previously AudioLevelMeter.RenderSegments created a new StackPanel, every segment
Border, and optional scale Grid/Canvas/ticks/labels, then cleared RootGrid. Both
level updates and the 33 ms decay timer used that path.

The host, panel, and scale now persist. The segment pool grows only to its maximum
requested size (48), and the scale pool only to seven marks. Smaller layouts
collapse surplus entries, preserving their identity for later reuse. Steady level
updates change brushes; geometry and scale text update only when layout changes.
Calibration, fit, peak hold, release ballistics, and muted-input coloring are retained.

Level updates cannot start a timer while unloaded. Unload stops the timer,
unsubscribes its handler, and releases the timer reference; load refreshes the
level and resumes ballistics using a fresh timer when needed.

## Validation

- Release WinUI shell build: passed, zero errors (existing warnings remain).
- Existing WinUI suite: 1,477 passed, zero failed/skipped.
- New standalone WinUI lifetime test: passed 10,000 updates, changing orientation,
level, mute, scale visibility, segment count, and size. All 48 segment and 14
scale-child identities remain stable. Checks responsive scale labels, segment
fit, normal/muted/pre-mute coloring, hidden scale, and unloaded timer suppression.
- Full collection/finalization runs off the UI thread while the dispatcher stays
available; the retained control remains usable afterward.

The executable compiles the production control and XAML into an isolated WinUI
application with no window. It starts no media engine or control API and writes no
operator settings. It exercises real XAML objects and layout; it does not simulate
Loaded/Unloaded by attaching a window. Live page navigation and the original idle
crash still need observation; a passing finite run cannot prove the crash absent.
The regression executable is included in the Windows CI job with a five-minute
timeout so a native hang cannot block the runner indefinitely.

Run locally:

```powershell
dotnet build native-shell/CoreVideoPro.WinUI.LifetimeTests/CoreVideoPro.WinUI.LifetimeTests.csproj -c Release -p:Platform=x64
& ./native-shell/CoreVideoPro.WinUI.LifetimeTests/bin/x64/Release/net9.0-windows10.0.19041.0/win-x64/CoreVideoPro.WinUI.LifetimeTests.exe
```

Next evidence for #513: retain matching binaries and any new idle-crash dump,
compare the failing reference-tracker path and object history, and investigate
other factories independently. Do not close the issue on reduced allocation
pressure or forced-GC survival alone.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<Platforms>x64</Platforms>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<UseWinUI>true</UseWinUI>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>
<!-- Compile the actual control into an isolated XAML host. No production
startup, media engine, settings, window, or control API is launched. -->
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK.WinUI" Version="2.3.6" />
<PackageReference Include="Microsoft.WindowsAppSDK.Runtime" Version="2.4.0" />
<PackageReference Include="Microsoft.WindowsAppSDK.InteractiveExperiences" Version="2.1.6" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4654" />
<Page Include="../CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml" Link="Controls/AudioLevelMeter.xaml" />
<Compile Include="../CoreVideoPro.WinUI/Controls/AudioLevelMeter.xaml.cs" Link="Controls/AudioLevelMeter.xaml.cs" />
<Compile Include="../CoreVideoPro.WinUI/Models/AudioMeterScale.cs" Link="Models/AudioMeterScale.cs" />
</ItemGroup>
</Project>
124 changes: 124 additions & 0 deletions native-shell/CoreVideoPro.WinUI.LifetimeTests/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Reflection;
using CoreVideoPro.WinUI.Controls;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.Foundation;

// Run on a real WinUI dispatcher, without opening a window or touching the
// operator's running application. This tests reuse, not reproduction of #513.
internal static class Program
{
private const BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic;
private static int _exitCode = 1;

[STAThread]
private static int Main()
{
WinRT.ComWrappersSupport.InitializeComWrappers();
Application.Start(_ =>
{
SynchronizationContext.SetSynchronizationContext(
new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()));
var app = new Application();
DispatcherQueue.GetForCurrentThread().TryEnqueue(async () =>
{
try
{
await RunAsync();
Console.WriteLine("PASS: meter identity, bounded pools, layout, fill, mute, and unloaded timer checks");
_exitCode = 0;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
}
finally { app.Exit(); }
});
});
return _exitCode;
}

private static async Task RunAsync()
{
var meter = new AudioLevelMeter { SegmentCount = 48, IsVertical = true, ShowDbfsScale = true };
var root = (Grid)meter.Content;
var panel = (StackPanel)root.Children[0];
var scale = (Canvas)root.Children[1];
var render = typeof(AudioLevelMeter).GetMethod("RenderSegments", PrivateInstance)!;
void Render(double width, double height)
{
meter.Measure(new Size(width, height));
meter.Arrange(new Rect(0, 0, width, height));
render.Invoke(meter, null);
}
Render(80, 600);
Check(panel.Children.Count == 48, "48-segment warmup");
Check(scale.Children.Count == 14, "seven scale marks");
var segments = panel.Children.ToArray();
var marks = scale.Children.ToArray();

for (var i = 0; i < 10000; i++)
{
meter.IsVertical = i % 2 == 0;
meter.ShowDbfsScale = i % 3 != 0;
meter.SegmentCount = 8 + i % 41;
meter.IsMuted = i % 5 == 0;
meter.ShowLevelWhileMuted = i % 7 == 0;
meter.Level = i % 101;
Render(80 + i % 300, new double[] { 40, 80, 600 }[i % 3]);
Check(ReferenceEquals(root, meter.Content), "root retained");
Check(panel.Children.Count == 48 && scale.Children.Count == 14, "bounded pools");
Check(segments.SequenceEqual(panel.Children), "segment identities retained");
Check(marks.SequenceEqual(scale.Children), "scale identities retained");
Check(panel.Orientation == (meter.IsVertical ? Orientation.Vertical : Orientation.Horizontal), "orientation");
Check(typeof(AudioLevelMeter).GetField("_decayTimer", PrivateInstance)!.GetValue(meter) is null,
"level changes cannot start unloaded timer");
}

meter.IsVertical = true;
meter.ShowDbfsScale = true;
meter.SegmentCount = 36;
foreach (var height in new[] { 40d, 80d, 600d })
{
Render(80, height);
var expected = height < 60 ? new[] { "0", "-30", "-60" }
: height < 100 ? new[] { "0", "-12", "-24", "-36", "-48", "-60" }
: new[] { "0", "-6", "-12", "-24", "-36", "-48", "-60" };
Check(scale.Children.OfType<TextBlock>().Where(x => x.Visibility == Visibility.Visible)
.Select(x => x.Text).SequenceEqual(expected), "responsive scale labels");
var visible = panel.Children.OfType<Border>().Where(x => x.Visibility == Visibility.Visible).ToArray();
Check(Math.Abs(visible.Sum(x => x.Height) + panel.Spacing * (visible.Length - 1) - height) < 0.01,
"segments fit available height");
}

meter.IsVertical = false;
meter.IsMuted = false;
meter.Level = 100;
Render(600, 40);
var first = (Border)panel.Children[0];
Check(((SolidColorBrush)first.Background).Color.G == 210, "green low segment");
meter.IsMuted = true;
meter.ShowLevelWhileMuted = false;
Render(600, 40);
Check(((SolidColorBrush)first.Background).Color.G == 30, "mute immediately darkens meter");
meter.ShowLevelWhileMuted = true;
Render(600, 40);
Check(((SolidColorBrush)first.Background).Color.G == 122, "pre-mute input color");
Check(scale.Visibility == Visibility.Collapsed, "horizontal scale hidden");
Check(Grid.GetColumnSpan(panel) == 2, "unscaled meter spans host");

// Leave the UI dispatcher free while finalizers run; blocking it can
// deadlock ordinary apartment-marshaled releases and is not a valid test.
await Task.Run(() => { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); });
Render(600, 40);
Check(segments.SequenceEqual(panel.Children), "control remains usable after full GC");
GC.KeepAlive(meter);
}

private static void Check(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,12 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

<Grid x:Name="RootGrid" />
<Grid x:Name="RootGrid">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="14" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel x:Name="SegmentPanel" Grid.ColumnSpan="2" />
<Canvas x:Name="ScaleCanvas" Grid.Column="1" Visibility="Collapsed" />
</Grid>
</UserControl>
Loading
Loading