Emit receiver-typed spawn edges for C# deferred-lambda registrations (Hangfire) - #399
Merged
Merged
Conversation
RecurringJob.AddOrUpdate<T>(x => x.M()) names the lambda receiver's type only in the generic argument, which extraction discarded — the call bound by name heuristics at best and jobs read as dead code. A registry of deferred-lambda clients (Hangfire first; per-API because a generic arg typing the lambda is a signature fact) emits EdgeSpawns with the *.M target + receiver_type hint resolveMethodCall already binds exactly. Inline Enqueue(() => M()) gains the spawn edge too.
zzet
approved these changes
Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Background-scheduler registrations like
RecurringJob.AddOrUpdate<SyncService>("id", x => x.SynchronizeAsync(), cron)name the lambda receiver's type only in the generic argument — which extraction discarded. The call inside the lambda bound by name heuristics at best, and job methods read as dead code. This PR carries that type through as thereceiver_typehint on anEdgeSpawns, riding two rails that already exist: the spawns kind (the scheduler runs the lambda later — the same semantics as theTask.Runemissions beside it) andresolveMethodCall's*.-target path, which binds receiver-typed calls exactly. No resolver changes, no new edge kinds.Changes
csharpDeferredLambdaClientsregistry (csharp_function_shape.go): static-client methods whose lambda a framework executes later — Hangfire'sRecurringJob.AddOrUpdate/BackgroundJob.Enqueue/Schedulefirst. Per-API by necessity: whether a generic argument types the lambda parameter is a signature fact —List.ConvertAll<TOutput>'s does not — so a blanket inference would emit confidently wrong edges. Other schedulers (Quartz, Coravel use a no-lambda invocable convention) are future rows/shapes, not blocked by this design.EdgeSpawns → unresolved::*.M+Meta{mode:"background", receiver_type:T}; the inlineEnqueue(() => M(…))form emits a bare-target spawn alongside its ordinary call edge, per theEdgeSpawnsdoc contract.AddHangfireServer(opts => { … })), which the previous walk pruned.Task.Run/awaitattribution semantics are unchanged (test-locked), and the lambda path stays allocation-free (byte-slice map index;Contentcopies only on the named-body path).Run/ExecuteAsync) and registrations cluster in one method, soEnqueue<Mailer>+Enqueue<Cleaner>each keep their edge.*.-target would only steer the resolver's name fallback onto an arbitrary same-named method: a type-parameter receiver (Enqueue<T>in a generic wrapper — checked against the enclosing declarations' actualtype_parameter_lists, not a naming guess, so a class namedTVShowkeeps its edge) and a captured-receiver inline call (Enqueue(() => job.Run())). Both were caught binding arbitrarily during validation.x => x.Prop) emits nothing rather than letting a later argument's lambda (the cron callback) substitute.Originis left for the resolver to stamp, so a binding rides the evidence tier it actually earns instead of a pre-stampedast_inferred.Known gaps (documented, not silent)
Program.csminimal hosting) are outside the method-body walk — a pre-existing extraction boundary this feature inherits.resolveMethodCall's name-only fallback like any receiver-typed call; the existing mis-bind guards (isCallLikeEdgein the cross-package guard,frameworkCallsForScope) are kind-filtered to calls/references and don't cover spawns. Extending their kind sets looks like a one-line follow-up each — happy to do it here or separately.Real-world results (reference Autofac + EF solution, 8,534 files)
All three direct registrations bind exactly, zero unresolved, zero false bindings:
RecurringJob.AddOrUpdate<T>with concrete job types, both inside anAddHangfireServerconfiguration lambda → bound to the two async job methods, previously dead-code false positivesBackgroundJob.Enqueue(() => Method(…))→ bound same-classEnqueue<T>(x => x.Run()) where T : <job interface>) correctly emits nothing — a documented gap rather than the arbitrary binding it produced mid-developmentTesting
go test -race ./...) — Linux-equivalent scope; Windows dev box shows only the known pre-existing failuresEdgeSpawnsbinds throughResolveAllto the right type's method over a same-named decoy)Checklist
emitCSharpAsyncSpawns's curated-API approach; same seen-dedup and edge shape)