Repository navigation
fix(container): stop a typed-nil pub/sub client escaping, and stop isNil panicking - #4164
Conversation
…Nil panicking Two related nil-handling defects in the container. The pub/sub constructors assign the result of google.New or kafka.New straight into Container.PubSub, and those return a TYPED nil when they reject an incomplete config -- a GOOGLE backend with no GOOGLE_PROJECT_ID, for instance. A typed nil inside an interface is not equal to nil, so every plain != nil guard downstream admitted it and then called a method on a nil receiver. Container.Health used such a guard where the SQL and Redis checks beside it already used isNil, so a request to the health endpoint panicked. Worse, App.Subscribe guards on GetSubscriber() == nil and then runs the client in an errgroup goroutine whose only recover wraps the user's handler, so the panic there escaped and killed the process at startup, before any request arrived. Filtering inside GetSubscriber fixes every caller at once. Doing that exposed the second defect. isNil called reflect.Value.IsNil unconditionally, and that PANICS on a value whose kind cannot be nil. A datasource field can legitimately hold one: implementing pubsub.Client, Redis or DB on a value receiver is ordinary Go, and gofr's own tests do it -- with the first fix alone, TestApp_SubscriberInitialize crashed on a struct-valued mock. Close and Health have called isNil on those same fields all along, so this was already reachable; routing GetSubscriber through it only made it easy to hit. isNil now checks the kind first and reports a non-nillable value as present, which it is.
3b07bde to
c508146
Compare
PiyushSingh-ZS
left a comment
There was a problem hiding this comment.
Both bugs are real, and I agree they have to ship together. One thing I think is missing before this lands.
Verified
kafka.Newis declaredfunc New(conf *Config, logger pubsub.Logger, metrics Metrics) *kafkaClientand returns a barenilwhenvalidateConfigsfails (pkg/gofr/datasource/pubsub/kafka/kafka.go). Assigned into thepubsub.Clientinterface that is a non-nil interface holding a nil pointer, soc.PubSub != niladmits it. The premise holds.reflect.Value.IsNildoes panic forStruct,String,IntandArray, so the second bug is reachable for any implementation on a value receiver.- The argument for one PR rather than two is right: fix 1 alone makes the panic in fix 2 newly reachable, because tightening the guard is exactly what routes value-receiver implementations into
isNil.
The Kind switch is the correct shape, and "anything not nillable is present by definition" is the right default.
GetPublisher was left behind
func (c *Container) GetPublisher() pubsub.Publisher {
return c.PubSub // unchanged
}
func (c *Container) GetSubscriber() pubsub.Subscriber {
if isNil(c.PubSub) { // fixed
return nil
}
return c.PubSub
}The reasoning in the new doc comment — "Filtering here fixes all of them at once" — applies identically to the publisher. A handler doing ctx.GetPublisher().Publish(...) against a typed-nil Kafka or Google client hits the same nil receiver; it just surfaces on a request instead of at startup.
Worth fixing in this PR rather than a follow-up, because the asymmetry is now more misleading than it was before: a reader seeing one of the two guarded will reasonably assume the guarded one is the house style and that the other was considered and deliberately left. A test case alongside TestGetSubscriberFiltersTypedNil would pin it.
Nits
assert.True(t, c.GetSubscriber() == nil)with the//nolint:testifylint: the justification is correct and genuinely subtle (a reflective nil check cannot fail here, sorequire.Nilwould pass with or without the filter). My only worry is that it reads as a lint violation to skim past, and someone will "fix" it back. A named helper —assertPlainNil(t, v)— would carry the intent to the next reader without relying on the comment being read.typedNilPubSubContainerleans onPUBSUB_BACKEND=GOOGLEwith no project id producing the typed nil. That is true today but it is an indirect way to construct the state under test, and it breaks ifgoogle.New's validation changes. Constructing the typed nil directly would make the test independent of that.
Interaction worth noting
If #4167 lands, its pubsub_backends_disabled.go stubs return an untyped nil for createMqttPubSub and leave c.PubSub unset for the other two, which composes correctly with this guard. Might be worth cross-linking so whoever merges second knows the two were considered together.
… something Review follow-ups on #4164. GetPublisher was left behind. It returns the same field as GetSubscriber, so a handler writing ctx.GetPublisher().Publish(...) against a typed-nil Kafka or Google client hits the identical nil receiver -- it just surfaces on a request instead of at startup. Guarding one and not the other is worse than guarding neither: the next reader has to guess whether the asymmetry was deliberate. TestHealthSurvivesTypedNilPubSub asserted NotPanics and was vacuous. runCheck already recovers (health.go:276), so the panic never escaped with or without the guard -- the test passed either way. What the guard actually prevents is a "pubsub": DOWN entry reading "health check panicked", which put the app into DEGRADED over a dependency it does not have. The test now asserts the pubsub key is absent, which fails when the guard is reverted. The comment above the guard is corrected the same way: the crash is on the GetSubscriber path, where App.Subscribe's errgroup has no recover, not here. The typed nil is now constructed directly rather than by way of google.New rejecting an incomplete GOOGLE config, so the tests no longer depend on what that constructor happens to validate. assertPlainNil replaces the inline //nolint:testifylint, carrying to the next reader why a reflective assert.Nil cannot be used here rather than relying on a comment being read.
Unrelated to this PR: a golang.org/x/net go.mod hash the workspace picked up while the tests were run locally.
|
All three addressed at
What the guard really prevents is this: An app with no usable pub/sub reporting a DOWN dependency and a degraded service, with a stack fragment for a reason. The test now asserts the The PR's claim that this path "took the process down" was overstated, and the comment above the guard is corrected: the crash is on the Nit 1 — the Nit 2 — the typed nil is constructed directly rather than by way of Cross-link added to #4167 for the |
The comment said health.go:276, which was never right: the recover is at 271 on development and 282 here. A line number in a comment is wrong the next time anything above it moves, so name the function instead.
Three of them asserted more than the tree supports: - "something GoFr's own tests do" for a struct value in a datasource field. They do not -- sqlMockDB's methods are on a value receiver but every assignment takes its address. The real justification is the public signature: App.AddPubSub and friends take an interface, so a caller can hand over a value, and nothing discourages it. - "took the process down" applied to every isNil caller. Health's does not: runCheck recovers. Close's does, via startShutdownHandler's goroutine, so name that one. - "escaped an errgroup with no recover" without saying where. The panic is in handleSubscription's Subscribe call, outside the recover it installs around the handler; x/sync v0.23.0 errgroup.go documents that it deliberately does not propagate panics from f. Comments only.
akshat-kumar-singhal
left a comment
There was a problem hiding this comment.
Approve with nits. isNil now covers every nillable kind (Chan, Func, Interface, Map, Pointer, Slice, UnsafePointer) with no panic path left, and the other callers (Close, the Health loop, migration's own isNil) behave correctly with it. Restoring container.go/health.go to base fails all four new tests (health reports "health check panicked", both getter tests fail, isNil panics on a struct).
Nit (arguably should-fix): root cause left in place — container.go:572, container.go:589
kafka.New/google.New can still store a typed nil in the exported Container.PubSub, so direct readers (ctx.Container.PubSub, future internal callers) get a non-nil interface wrapping a nil receiver. Suggest only assigning a real client, e.g. if cl := google.New(...); cl != nil { c.PubSub = cl }, keeping the getter filtering as defence in depth.
Nit — container.go:442: ctx.GetPublisher().Publish(...) still panics — now a nil-interface panic instead of a nil-receiver one. The guard helps callers that check != nil; the comment shouldn't imply the panic goes away.
Nit (tests): TestIsNilHandlesNonNillableKinds doesn't cover nil map/slice/chan/func; nothing tests end-to-end that App.Subscribe refuses a typed-nil client (the crash described), and no app-level struct-valued pub/sub mock test was added despite the commit message mentioning TestApp_SubscriberInitialize.
…he getters Review findings from akshat-kumar-singhal on #4164. **The root cause is fixed rather than only filtered.** kafka.New and google.New return a bare nil of their concrete type when they reject a config, and assigning that straight into the pubsub.Client interface is what manufactures the typed nil: a non-nil interface holding a nil pointer, which every `!= nil` check in the codebase admits. Container.PubSub is exported, so a direct reader -- ctx.Container.PubSub, or any future internal caller -- gets no filtering at all. Both constructors now assign only a real client, and the getters stay as defense in depth rather than as the only thing between a misconfiguration and a nil receiver. TestRejectedPubSubConfigLeavesTheExportedFieldNil pins it with a plain == nil comparison, because assert.Nil reflects and would pass on the typed nil. Reverting the google guard fails it with "got a *google.googleClient in the interface". **The GetPublisher comment no longer overclaims.** An unconditional ctx.GetPublisher().Publish(...) still panics, now on a nil interface instead of a nil receiver; what the filter fixes is every caller that does check. The comment says so. **isNil's nillable kinds are now covered exhaustively** -- map, slice, chan, func, interface and unsafe.Pointer alongside the pointer, in both directions. The Kind switch enumerates them, so a kind dropped from it would otherwise fall through to "not nillable, therefore present": the wrong answer, in the safe-looking direction. Verified: gofmt, go vet, typos and go test clean; golangci-lint reports 1 goconst, identical to the merge base. A single TestIntegration_ServerTimeout failure seen while running two packages together did not reproduce -- 4/4 clean afterwards, and 3/3 in isolation.
|
Thanks @akshat-kumar-singhal — the root-cause item is taken, in Root cause: fixed at the constructorsYou were right that filtering at the getters leaves the bad value in an exported field.
The assertion is a plain The GetPublisher comment no longer overclaimsYour reading is correct and the comment now says it outright: an unconditional isNil coverageExtended to every nillable kind in both directions — map, slice, chan, func, interface and Not done, and whyThe end-to-end Verification
One honest note: a single |
Umang01-hash
left a comment
There was a problem hiding this comment.
Deep-reviewed and verified end-to-end at head a91b62c1. Both bugs are real and the coupling is right (fix 1 alone makes the isNil panic reachable for value-receiver implementers).
Verified independently:
- Not breaking: isNil is unexported; GetPublisher/GetSubscriber/Health signatures unchanged (pre-existing methods); constructors internal.
- Constructor guard sound: kafka.New→kafkaClient, google.New→googleClient (concrete pointers), so
client != nilcatches the nil correctly. - isNil rewrite correct: switches on Kind first, covering exactly the 7 nillable kinds; everything else is present.
- 6 fail-on-revert tests: reverted both files — each fails reproducing the exact symptoms (health
"pubsub": DOWN "health check panicked"→ DEGRADED;panic: reflect: call of reflect.Value.IsNil on struct Value). Tests assert honestly (plain== nil, not reflective assert.Nil; health key ABSENT, not NotPanics). - Real app E2E: with a rejected GOOGLE config,
developmentcrashes at startup (SIGSEGV in the errgroup goroutine, no recover); this PR starts clean ("subscriber not initialized"). Exactly the claimed process-death path. - gofmt/vet/build/-race clean, golangci-lint 0 issues, coverage 9.0%→9.1%.
The deferred AddDBResolver == nil is correctly left to its own PR. LGTM.
Conflict in pkg/gofr/container/container.go: development (#4164) changed createKafkaPubSub and createGooglePubSub to assign c.PubSub only when the constructor returns a real client, so a rejected config no longer leaves a typed nil in the exported field. This branch had moved both functions to pubsub_backends.go behind the gofr_nopubsub tag, so the conflict is resolved by keeping them there and carrying #4164's guard (and its comments) over to the new location. Re-measured the slim-builds figures at this merge, darwin/arm64, Go 1.26.3: 827 packages by default, 614 / 783 / 807 per tag, 550 with all three; examples/http-server 60,008,402 -> 43,384,642 bytes, 27.7%.
Two conflicts, both where this branch moved code behind a build tag and development edited it in place: - metrics/exporters/otlp.go: development's otel v1.45 signal-path repair (a path-less METRICS_URL posting to "/") lands in otlp_transport.go, where buildOTLPExporter now lives, with httpEndpointWithSignalPath beside it. Its new otlp_signalpath_test.go is tagged !gofr_nootlp like the other transport tests. - container/container.go: #4164's typed-nil guard on the Kafka and Google constructors is carried to pubsub_backends.go. Re-measured the slim-builds counts at this merge, darwin/arm64, Go 1.26.3: 827 default; 614 / 787 / 783 / 807 / 817 / 825 per tag; 411 with all six.
Description:
Two bugs in the same guard, which have to ship together.
1. A typed nil passes
!= nil.kafka.Newandgoogle.Newreturn a typed nil when they reject an incomplete config. Assigned into thePubSubinterface, that is not equal tonil— sohealth.goadmitted it and calledHealth()on a nil receiver.SQLandRedisbeside it already usedisNil. This guard was the odd one out. Also filtered inGetSubscriber()andGetPublisher(), so the typed nil cannot escape the container at all. Both, not one: they return the same field, so a handler writingctx.GetPublisher().Publish(...)hits the identical nil receiver — it just surfaces on a request rather than at startup, and guarding only one leaves the next reader guessing whether the asymmetry was deliberate.2.
isNilitself panicked.reflect.Value.IsNilpanics on a value whose kind cannot be nil (reflect/value.go, thepanic(&ValueError{"reflect.Value.IsNil", ...})at the end ofIsNil).App.AddPubSub,App.AddMongoand the rest take an interface, so a caller whose implementation has value receivers can hand over a struct rather than a pointer — ordinary Go, and nothing in the signature discourages it. GoFr writes such implementations itself (sqlMockDB's methods are on a value receiver); it just happens to pass them by address, which is why nothing in-tree hits this today.That panic took the process down:
Container.ClosecallsisNil, and it is reached from the shutdown goroutine instartShutdownHandler, which has no recover.isNilnow switches onKindfirst; anything not nillable is present by definition.What each guard actually prevents
Worth stating precisely, because the two paths differ:
GetSubscriber→App.SubscribeGetSubscriber() == nilguard admits the typed nil, the subscription is registered, andhandleSubscriptionthen callsSubscribeon the nil receiver — outside the recover it installs around the handler.errgroupdoes not recover either:golang.org/x/sync@v0.23.0/errgroup/errgroup.godocuments that it deliberately does not propagate panics fromf()GetPublisher→ a handlerHealthrunCheck's deferred recover catches it. It surfaced as a"pubsub": DOWNentry readinghealth check panicked, putting the app into DEGRADED over a dependency it does not haveClose(bug 2 only)Breaking Changes (if applicable):
None — and fix 2 is what keeps it that way.
Additional Information:
pubsub_health_nil_test.gocovers the typed nil throughHealth,GetSubscriberandGetPublisher, plus a value-receiver implementation that panicked before. The typed nil is constructed directly rather than by way ofgoogle.Newrejecting an incomplete config, so the tests do not depend on what that constructor happens to validate.pubsubkey is absent from the health map, notNotPanics.runCheckalready recovers, so aNotPanicsassertion passes with or without the guard and proves nothing; the absent key fails when the guard is reverted. Verified by reverting each guard locally:TestHealthOmitsTypedNilPubSub,TestGetSubscriberFiltersTypedNilandTestGetPublisherFiltersTypedNilall fail without it, andTestIsNilHandlesNonNillableKindsfails without theKindswitch.isNilon the accessors: +2.0 ns/op, 0 B/op, 0 allocs/op. Measured on-benchtime=5000000x -count=5 -cpu=1, Go 1.26.3 / darwin-arm64 (Apple M4):isNilon a real client 2.35 ns/op (0 B, 0 allocs) against 0.33 ns/op (0 B, 0 allocs) for a plain!= nil. Typed-nil and unset cases are cheaper still (1.98 / 1.31 ns/op).GetPublisheris the one that can sit on a per-request path, and 2 ns against a network publish is not a trade worth the nil-receiver panic. (An earlier revision of this description quoted +5.5 ns from 6.8 vs 1.3 ns; that figure did not reproduce and is superseded by the numbers above.)isNilcarries//nolint:exhaustive— thedefaultbranch is the point, and the repo setsdefault-signifies-exhaustive: false. Same convention asmetrics/exporters/otlp.go.PubSubis not the only field reachable as a typed nil —sql.NewSQLreturns*sql.DBandredis.NewClientreturns*redis.Redis, both of which returnnilon a rejected config and are assigned straight into their interface fields. Every guard on them already usesisNil, with one exception:AddDBResolverinexternal_db.gocomparesa.container.SQL == nil, so its "Primary SQL connection must be configured"Fataldoes not fire when SQL config was rejected. That is the only remaining plain comparison against a datasource field in the repo (grep -rEn '\.(SQL|Redis|PubSub|Mongo|…) *[!=]= *nil', non-test). Not touched here — it is a different call path and belongs in its own change.pubsub_backends_disabled.gostubs return an untypednilforcreateMqttPubSuband leavec.PubSubunset for the other two, which this guard handles unchanged.Checklist:
goimportandgolangci-lint.