What's wrong
Both synchronous overloads unwrap the AggregateException from Task.Wait() / .Result like this (Invoker/Invoker.cs, Invoke(Action) and Invoke<TReturn>(Func<TReturn>)):
catch (AggregateException ex)
{
throw ex.InnerException ?? ex;
}
throw someException resets that exception's StackTrace to the throw site. So whenever the delegate is marshalled to the owner thread and throws, the exception the caller receives has a stack trace that starts at Invoker.Invoke — every frame of the delegate that actually failed is gone.
Repro
Standalone copy of Invoker.cs, owner thread pumping DoInvokes(), a worker thread calling:
static void Thrower() => throw new InvalidOperationException("x");
inv.Invoke(() => Thrower()); // from a non-owner thread
Caught StackTrace:
at ktsu.Invoker.Invoker.Invoke(Action func) in Invoker.cs:line 95
at Program.<>c__DisplayClass0_0.<<Main>$>b__1() in Program.cs:line 11
Thrower doesn't appear anywhere. (The same call made on the owner thread, where the delegate runs inline, keeps the full trace, so the same failure is diagnosable or not depending on which thread made the call.)
Why it matters
The invoker exists for marshalling onto UI/GL/render threads, and failures there are usually deep inside rendering or GPU-resource code. Crash reports and logs from cross-thread calls point only at Invoker.Invoke, with nothing showing where the failure came from.
Suggested fix
Rethrow with ExceptionDispatchInfo, which keeps the original trace and appends the rethrow site:
catch (AggregateException ex) when (ex.InnerException is not null)
{
ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
throw; // unreachable
}
Or skip the AggregateException entirely with InvokeAsync(func).GetAwaiter().GetResult(), which rethrows the original exception with its trace intact.
Acceptance: a test that calls Invoke from a non-owner thread with a delegate that throws from a named helper method, and checks that the caught exception's StackTrace contains that helper's name. Cover both overloads.
What's wrong
Both synchronous overloads unwrap the
AggregateExceptionfromTask.Wait()/.Resultlike this (Invoker/Invoker.cs,Invoke(Action)andInvoke<TReturn>(Func<TReturn>)):throw someExceptionresets that exception'sStackTraceto the throw site. So whenever the delegate is marshalled to the owner thread and throws, the exception the caller receives has a stack trace that starts atInvoker.Invoke— every frame of the delegate that actually failed is gone.Repro
Standalone copy of
Invoker.cs, owner thread pumpingDoInvokes(), a worker thread calling:Caught
StackTrace:Throwerdoesn't appear anywhere. (The same call made on the owner thread, where the delegate runs inline, keeps the full trace, so the same failure is diagnosable or not depending on which thread made the call.)Why it matters
The invoker exists for marshalling onto UI/GL/render threads, and failures there are usually deep inside rendering or GPU-resource code. Crash reports and logs from cross-thread calls point only at
Invoker.Invoke, with nothing showing where the failure came from.Suggested fix
Rethrow with
ExceptionDispatchInfo, which keeps the original trace and appends the rethrow site:Or skip the
AggregateExceptionentirely withInvokeAsync(func).GetAwaiter().GetResult(), which rethrows the original exception with its trace intact.Acceptance: a test that calls
Invokefrom a non-owner thread with a delegate that throws from a named helper method, and checks that the caught exception'sStackTracecontains that helper's name. Cover both overloads.