diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef82cbb7..52278707 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,6 +136,7 @@ jobs: frontend/roslyn/samples/DiCaptiveSample.cs \ frontend/roslyn/samples/SampleTypes.cs \ frontend/roslyn/samples/PipeFieldsSample.cs \ + frontend/roslyn/samples/AppLifetimeSample.cs \ -o "$RUNNER_TEMP/facts.json" cat "$RUNNER_TEMP/facts.json" - name: Check facts through the core @@ -283,6 +284,14 @@ jobs: if echo "$out" | grep -q "CleanStaticEventViewModel"; then echo "FAIL: an unsubscribed (released) static-event subscription was wrongly reported"; exit 1 fi + # P-004 process-lived-subscriber exemption (mined: ScreenToGif App + + # Translator): the WPF `App` singleton hooking the process-lived + # AppDomain.UnhandledException promotes nothing, so the static-source region + # escape (OWN014) must NOT fire — for both the name-based (`partial class + # App`) and base-based (`: Application`) shapes. + if echo "$out" | grep -q "AppLifetimeSample.cs"; then + echo "FAIL: a process-lived App static-event subscription was wrongly reported (OWN014 FP)"; exit 1 + fi # P-006 DI001 (captive dependency): the registration + constructor graph # extracted from DiCaptiveSample.cs feeds ownlang/di.py. A singleton that # captures a scoped service — directly, transitively through a transient, diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index e83ee2b9..091baec2 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -189,6 +189,37 @@ static bool IsStaticHandler(ExpressionSyntax right, SemanticModel model) => IsHandler(right) && model.GetSymbolInfo(right).Symbol is IMethodSymbol { IsStatic: true }; +// P-004 process-lived-subscriber exemption: the WPF application object (`App`) is a +// process-lived singleton — exactly one instance, created at startup, alive until +// the process exits. Subscribing it to a process-lived static event +// (`AppDomain.CurrentDomain.UnhandledException`, `SystemEvents.*`) promotes nothing: +// its "leaked" lifetime already equals the process. So a static-source region escape +// (OWN014) raised from inside `App` is a false positive (found mining ScreenToGif +// and its bundled Translator tool, both flagged on the textbook unhandled-exception +// hook). Detected syntactically because WPF does not resolve on the Linux runner: +// either the class derives from `Application` / `System.Windows.Application`, or it +// is the conventional XAML-split `partial class App` (whose `: Application` lives in +// the generated `App.g.cs` partial the extractor never sees). Only the STATIC-source +// escape is suppressed; an instance-field subscription leak inside `App` still fires. +static bool IsProcessLivedApplication(TypeDeclarationSyntax cls) +{ + if (cls.BaseList is { } bl) + foreach (var bt in bl.Types) + { + var n = bt.Type switch + { + IdentifierNameSyntax id => id.Identifier.Text, + QualifiedNameSyntax q => q.Right.Identifier.Text, + AliasQualifiedNameSyntax aq => aq.Name.Identifier.Text, + _ => null, + }; + if (n is "Application") + return true; + } + return cls.Identifier.Text == "App" + && cls.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)); +} + // P-004 severity tiering: of the subscriptions that survive the self-owned and // static-handler exemptions (and are not timers), how long-lived is the event // SOURCE? A static event lives for the whole process, so an undetached handler is @@ -1906,6 +1937,10 @@ or ImplicitObjectCreationExpressionSyntax && IsTemplatePartFetch(a.Right)) selfOwned.Add(tf.Name); + // Is this class the process-lived WPF application object? Used to drop the + // static-source region escape (OWN014) — `App` cannot be over-promoted. + var clsIsApp = IsProcessLivedApplication(cls); + var subs = new List(); foreach (var a in assigns) { @@ -1938,6 +1973,11 @@ or ImplicitObjectCreationExpressionSyntax : SubscriptionSourceKind(a.Left, ev, model); if (source == "local") continue; + // Process-lived subscriber (the WPF `App` singleton): a static-source + // subscription promotes nothing — `App` already lives for the whole + // process — so the region escape (OWN014) is a false positive. + if (source == "static" && clsIsApp) + continue; var released = unsub.Contains($"{a.Left}|{a.Right}") || (isTimer && Receiver(a.Left) is { } recv && stopped.Contains(recv)); subs.Add(new diff --git a/frontend/roslyn/samples/AppLifetimeSample.cs b/frontend/roslyn/samples/AppLifetimeSample.cs new file mode 100644 index 00000000..a7dbe670 --- /dev/null +++ b/frontend/roslyn/samples/AppLifetimeSample.cs @@ -0,0 +1,46 @@ +using System; +using System.Windows; + +namespace Own.Samples.WpfApp; + +// P-004 process-lived-subscriber exemption (mined from ScreenToGif + its Translator). +// +// The WPF application object (`App`) is a process-lived singleton: subscribing it to +// a process-lived static event (AppDomain.CurrentDomain.UnhandledException) is the +// textbook global-exception hook and promotes nothing — App already lives for the +// whole process. So the static-source region escape (OWN014) must NOT fire on it. +// Two detection shapes, both must stay SILENT: + +// (1) name-based: ScreenToGif's real shape — `partial class App` whose `: Application` +// lives in the generated `App.g.cs` partial the extractor never sees (here the +// only visible base is IDisposable). +public partial class App : IDisposable +{ + private void App_Startup(object sender, EventArgs e) + { + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + } + + private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) + { + Console.WriteLine(e.ExceptionObject); + } + + public void Dispose() { } +} + +// (2) base-based: a custom-named application object deriving from `Application` +// directly in the .cs (the base stays unresolved on the Linux runner, but the +// syntactic base-name detection still applies). +public class BootstrapApp : Application +{ + public void Init() + { + AppDomain.CurrentDomain.UnhandledException += OnUnhandled; + } + + private void OnUnhandled(object sender, UnhandledExceptionEventArgs e) + { + Console.WriteLine(e.ExceptionObject); + } +}