Fixes #685: guard get_models() against re-entrancy - #687
Conversation
Generating a brand-new CustomObjectType model can itself trigger Django to rebuild its global relation graph (Options._relation_tree), e.g. via ObjectType.objects.get_for_model()'s .create() in the executor path (#685), or a polymorphic field's related_object_types.all() query in the descriptor-wiring path (#686, closed as a duplicate of this one). Rebuilding that graph calls apps.get_models() again, re-entering this plugin's own get_models() while it's still mid-generation -- which walked CustomObjectType.objects.all() and called get_model() again for every COT, including the one still under construction, with no way to ever finish. Per jnovinger's review on #685, a re-entrancy guard on get_models() itself (rather than an executor-only fix) is the right level: #686 hits the identical get_models() recursion through a completely different call site untouched by the executor, so any fix scoped to the executor would leave that path -- and any future path into the same hazard -- live. A ContextVar-based guard (matching the existing _is_migrating idiom in this file) is safe here because generate_model()'s type() call already registers a COT's model with Django's app registry synchronously, before get_model() ever calls _after_model_generation() (the method that can trigger this re-entrancy) -- so a re-entrant call can simply fall back to super().get_models() (already-registered models) without needing to regenerate anything. Adds regression tests for both re-entry paths. Both needed one non-obvious adjustment to actually exercise the vulnerable code: get_models()'s CustomObjectType-enumeration loop is unconditionally disabled under `manage.py test` (should_skip_dynamic_model_creation() returns True whenever "test" in sys.argv), so tests patch _app_ready / should_skip_dynamic_model_creation to replicate a non-test process. Verified each test reproduces a genuine RecursionError against this commit's parent and passes with the fix. Verified: full netbox_custom_objects suite in the shared dev venv, 1179 tests, 0 new failures (15 pre-existing errors, all attributable to the sibling netbox-branching checkout's known incompatibility with this venv's Django/NetBox version -- confirmed to fail identically in isolation, unrelated to this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rns_400
An earlier edit in this branch accidentally removed
self.assertIn("allow_destructive", resp.data) from this pre-existing,
unrelated test while inserting the new reentrancy test classes after
it. Caught by code review.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Condensed the re-entrancy guard's explanation and the new tests' docstrings/comments down to the non-obvious why, dropping restated mechanics that duplicate what the code and PR description already say. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@claude /review and limit response to actionable issues. |
|
Claude finished @bctiemann's task in 2m 55s —— View job Code Review
The core fix is correct and well-reasoned. 1.
|
|
Re: finding #1 (Python 3.10 compatibility) -- looked into this. NetBox core itself requires Python 3.12+ as of v4.5 ( Leaving Finding #2 (repeated inline imports) is a fair, uncontroversial cleanup -- fixing that now. |
mock, django_apps, and nco_pkg were each re-imported inline in all three SchemaApplyMultiCOTRecursionTestCase methods; moved to the top of the file alongside the existing imports. Per review on #687. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes: #685
Summary
POST /api/plugins/custom-objects/schema/apply/raisedRecursionErrorwhen a document created two or more new, cross-referencing Custom Object Types in a single request. Generating a brand-new COT's model calls_after_model_generation(), which can itself need Django's global relation graph (Options._relation_tree) -- e.g. viaObjectType.objects.get_for_model()'s.create(). Rebuilding that graph callsapps.get_models(), which re-enters this plugin's ownget_models()while it's still mid-generation. Without a guard, that re-entrant call walksCustomObjectType.objects.all()and callsget_model()again for every COT -- including the one still under construction -- with no way to ever finish.#686 (closed as a duplicate) hit the identical
get_models()recursion through a completely different call site:_wire_polymorphic_reverse_descriptors()->field_instance.related_object_types.all(), triggered during ordinary polymorphic-field save/modify, not the executor at all.Evaluating the proposed fix
The issue's own investigation laid out three options, roughly by invasiveness: (1) serialize COT finalization in the executor, (2) pre-create
ObjectTyperows before any FK reference resolves, (3) a re-entrancy guard onget_models()itself. @jnovinger's review recommended option 3 specifically because of #686 -- an executor-only fix (1 or 2) would resolve #685 but leave #686, and any future path into the same hazard, live. This PR implements option 3.A
contextvars.ContextVar-based guard (matching the existing_is_migratingidiom already in this file) is safe here becausegenerate_model()'stype()call already registers a COT's model with Django's app registry synchronously, beforeget_model()ever calls_after_model_generation()(the method that can trigger this re-entrancy). So a re-entrant call can simply fall back tosuper().get_models()(everything already registered) without needing to regenerate anything -- any COT the outer loop hasn't reached yet is just absent from that transient snapshot, and self-heals viaget_model()'s ownapps.clear_cache()once the remaining COTs finish generating.Testing
Per jnovinger's explicit ask, adds a regression test for each re-entry path:
SchemaApplyMultiCOTRecursionTestCase(test_schema_api.py) -- the executor path fromschema/apply/raises RecursionError when a document creates multiple new, cross-referencing Custom Object Types in one request #685's own repro (2-type and 3-type chained-reference documents posted through the real API view).PolymorphicReverseDescriptorRecursionTestCase(test_polymorphic_fields.py) -- the descriptor-wiring path from RecursionError when accessing COT models with polymorphic reverse descriptors #686.Both needed one non-obvious adjustment to actually exercise the vulnerable code:
get_models()'sCustomObjectType-enumeration loop is unconditionally disabled undermanage.py test(should_skip_dynamic_model_creation()returnsTruewhenever"test" in sys.argv), so without patching around that, these tests silently no-op regardless of the fix. Both tests now patch_app_ready/should_skip_dynamic_model_creationto replicate a non-test process, and each was verified to reproduce a genuineRecursionErroragainst this commit's parent, then pass cleanly with the fix.test_schema_api.pyalso gets a third, deterministic white-box test (test_get_models_guards_against_reentrant_cot_generation) that simulates the re-entrant trigger directly via a call-counting spy onget_model(), so the invariant is verified independent of whatever incidental factors (cache warmth, interpreter version, stack depth) determine whether a given environment happens to blow past Python's recursion limit.Full
netbox_custom_objectssuite verified in the shared dev venv: 1179 tests, 0 new failures (15 pre-existing errors, all attributable to the siblingnetbox-branchingcheckout's known incompatibility with this venv's Django/NetBox version -- confirmed to fail identically in isolation, unrelated to this change).