Skip to content

Invoke() rethrows the inner exception with throw ex.InnerException, discarding the stack trace of the code that actually failed #54

Description

@matt-edmondson

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions