Skip to content

Emit receiver-typed spawn edges for C# deferred-lambda registrations (Hangfire) - #399

Merged
zzet merged 1 commit into
zzet:mainfrom
pbednarcik:feat/csharp-hangfire-jobs
Jul 29, 2026
Merged

Emit receiver-typed spawn edges for C# deferred-lambda registrations (Hangfire)#399
zzet merged 1 commit into
zzet:mainfrom
pbednarcik:feat/csharp-hangfire-jobs

Conversation

@pbednarcik

Copy link
Copy Markdown

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 the receiver_type hint on an EdgeSpawns, riding two rails that already exist: the spawns kind (the scheduler runs the lambda later — the same semantics as the Task.Run emissions beside it) and resolveMethodCall's *.-target path, which binds receiver-typed calls exactly. No resolver changes, no new edge kinds.

Changes

  • csharpDeferredLambdaClients registry (csharp_function_shape.go): static-client methods whose lambda a framework executes later — Hangfire's RecurringJob.AddOrUpdate / BackgroundJob.Enqueue / Schedule first. 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.
  • Generic forms emit EdgeSpawns → unresolved::*.M + Meta{mode:"background", receiver_type:T}; the inline Enqueue(() => M(…)) form emits a bare-target spawn alongside its ordinary call edge, per the EdgeSpawns doc contract.
  • The spawn walk now descends into lambdas for deferred-client detection only — registrations standardly live inside configuration lambdas (AddHangfireServer(opts => { … })), which the previous walk pruned. Task.Run/await attribution semantics are unchanged (test-locked), and the lambda path stays allocation-free (byte-slice map index; Content copies only on the named-body path).
  • Dedup is keyed per receiver: job classes conventionally share method names (Run/ExecuteAsync) and registrations cluster in one method, so Enqueue<Mailer> + Enqueue<Cleaner> each keep their edge.
  • Two no-type-evidence shapes deliberately emit nothing, because a hint-less *.-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' actual type_parameter_lists, not a naming guess, so a class named TVShow keeps its edge) and a captured-receiver inline call (Enqueue(() => job.Run())). Both were caught binding arbitrarily during validation.
  • An invocationless job lambda (x => x.Prop) emits nothing rather than letting a later argument's lambda (the cron callback) substitute.
  • Origin is left for the resolver to stamp, so a binding rides the evidence tier it actually earns instead of a pre-stamped ast_inferred.

Known gaps (documented, not silent)

  • Registrations inside local functions and top-level statements (Program.cs minimal hosting) are outside the method-body walk — a pre-existing extraction boundary this feature inherits.
  • A receiver type that is not indexed (external-assembly job) falls through resolveMethodCall's name-only fallback like any receiver-typed call; the existing mis-bind guards (isCallLikeEdge in 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 an AddHangfireServer configuration lambda → bound to the two async job methods, previously dead-code false positives
  • 1× inline BackgroundJob.Enqueue(() => Method(…)) → bound same-class
  • The solution's generic scheduler wrapper (Enqueue<T>(x => x.Run()) where T : <job interface>) correctly emits nothing — a documented gap rather than the arbitrary binding it produced mid-development

Testing

  • All tests pass (go test -race ./...) — Linux-equivalent scope; Windows dev box shows only the known pre-existing failures
  • New tests: extractor (generic + inline + config-lambda + type-param + negative receivers) and resolver (receiver-typed EdgeSpawns binds through ResolveAll to the right type's method over a same-named decoy)

Checklist

  • Code follows existing patterns in the codebase (extends emitCSharpAsyncSpawns's curated-API approach; same seen-dedup and edge shape)
  • No unnecessary abstractions added

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
zzet merged commit e36a06d into zzet:main Jul 29, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants