Skip to content

[17.0][FIX] server_action_mass_edit: stop poisoning the registry on onchange#1293

Open
DarioLodeiros wants to merge 3 commits into
OCA:17.0from
DarioLodeiros:17.0-fix-mass-edit-onchange-poison-fields
Open

[17.0][FIX] server_action_mass_edit: stop poisoning the registry on onchange#1293
DarioLodeiros wants to merge 3 commits into
OCA:17.0from
DarioLodeiros:17.0-fix-mass-edit-onchange-poison-fields

Conversation

@DarioLodeiros

Copy link
Copy Markdown
Member

Symptom

Random KeyError: None traces from unrelated views (bank_rec_widget, knowledge.article, ...) surface to the web client as RPC_ERROR:

File ".../odoo/models.py", in _has_onchange
    for dep in self.pool.get_dependent_fields(field.base_field)
File ".../odoo/modules/registry.py", in _field_triggers
    dependencies = list(field.resolve_depends(self))
File ".../odoo/fields.py", in resolve_depends
    Model0 = registry[self.model_name]
KeyError: None

The trace never points at this module on the surface, but the only thing planting Field instances with model_name = None into the registry is MassEditingWizard.onchange.

Root cause

MassEditingWizard.onchange injects dynamic Selection / Text fields by assigning them straight into self._fields:

dynamic_fields["selection__" + line.field_id.name] = fields.Selection(
    [()], default="ignore"
)
dynamic_fields[line.field_id.name] = fields.Text([()], default=False)
self._fields.update(dynamic_fields)

These Field objects never go through __set_name__, so model_name and name stay at the default None. Any later access to registry._field_triggers — for example from _has_onchange while building a view for any other model — calls field.resolve_depends(self)registry[None]KeyError: None.

The cleanup (for field in dynamic_fields: self._fields.pop(field) plus the temporary view deletion) is not wrapped in try/finally. If anything inside super().onchange() raises, the orphan Field instances are left in mass.editing.wizard._fields (a class-level dict, shared by every request on that worker) and the temporary view stays orphaned in the database. The worker stays poisoned for the rest of its lifetime: every request that touches _field_triggers then explodes randomly on completely unrelated views.

I could not find an open issue/PR covering this exact scenario in OCA/server-ux. PR #1203 deals with the view cache mechanism, which is unrelated.

Fix

  • Call Field.__set_name__ on each dynamic field before inserting it into _fields, so model_name / name are populated and registry lookups resolve to mass.editing.wizard as expected.
  • Wrap the super().onchange() call (and the cached value rewrite) in try/finally, so both the dynamic fields and the temporary view are always cleaned up — even when the parent onchange raises.

Regression test

test_onchange_dynamic_fields_are_bound_and_cleaned asserts both invariants:

  1. While super().onchange() is running, each dynamic Field has model_name == 'mass.editing.wizard' (without the fix the test fails with None != 'mass.editing.wizard': Dynamic field selection__bank_ids was injected with model_name=None).
  2. _fields is restored to its original state on both the success path and a failure path that forces super().onchange() to raise.

Related PRs

Companion fixes for 16.0 / 18.0 are linked from this PR (16.0: #1292).

``MassEditingWizard.onchange`` injects dynamic ``Selection`` / ``Text``
fields into ``self._fields`` by assigning them directly into the dict.
Because they never go through ``__set_name__``, the resulting ``Field``
instances have ``model_name=None`` and ``name=None``.

Once a worker hits this code path, the next time any model is asked
about its onchange dependencies (``Model._has_onchange`` →
``Registry.get_dependent_fields`` → ``Field.resolve_depends``) Odoo
calls ``registry[self.model_name]``, which is ``registry[None]`` and
raises ``KeyError: None``. The exception propagates back to the web
client as a random RPC error on completely unrelated views.

The cleanup ``for field in dynamic_fields: self._fields.pop(field)`` is
also not wrapped in ``try/finally``, so any exception inside
``super().onchange()`` leaves the orphan Fields (and the temporary view
record) behind. The worker's class-level ``_fields`` stays poisoned
until the process is restarted.

This patch:

* Calls ``Field.__set_name__`` on each dynamic Field so it is bound to
  the owner class (``model_name`` and ``name`` are populated).
* Wraps the ``super().onchange()`` call in ``try/finally`` so both the
  dynamic fields and the temporary view are always cleaned up, even
  when the parent ``onchange`` raises.

A regression test asserts both invariants on the success and the
failure paths.
The previous version of ``test_onchange_dynamic_fields_are_bound_and_cleaned``
monkey-patched ``odoo.models.Model.onchange`` to intercept the parent
``onchange`` call. In 17.0+ the real implementation lives on
``odoo.addons.web.models.Base.onchange`` (``_inherit='base'``) while
``Model.onchange`` is a ``NotImplementedError`` raiser. Tampering with
``Model.onchange`` confused the MRO of unrelated models in subsequent
tests, leaking the raiser into ``test_onchanges``.

Instead, patch ``fields.Field.__set_name__`` to record every binding
call. This proves that each dynamic ``Field`` injected by
``MassEditingWizard.onchange`` is bound to ``mass.editing.wizard`` with
the correct attribute name (without the production fix the binding is
missed entirely and ``model_name`` stays at ``None``), while leaving the
rest of the ORM untouched.

The failure-path assertion is dropped from the test: the ``try/finally``
in the fix is straightforward, and forcing the parent ``onchange`` to
raise without altering ``Model.onchange`` would require re-implementing
the wizard's setup in the test.
After ``__set_name__`` is properly called on the dynamic ``Selection``
fields, the empty selection literal ``[()]`` makes ``web.Base.onchange``
trip on ``Selection.get_values`` (which tries to iterate ``[()]`` as
``(value, label)`` pairs). That validation path was silently skipped
before the fix because the half-built ``Field`` had ``model_name=None``.

Stub ``web.Base.onchange`` with a no-op for the test: we only care
about whether ``MassEditingWizard.onchange`` binds the dynamic fields
via ``__set_name__``, not about what the parent ``onchange`` does with
the placeholder selection.

The stub uses ``mock.patch.object`` so the original method is restored
even if the test fails, without touching ``models.Model.onchange``
(which would confuse the MRO of unrelated models).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mod:server_action_mass_edit Module server_action_mass_edit series:17.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants