Skip to content

_sanitize leaks IntEnum/StrEnum members onto the settings wire, breaking subscribers without the defining package #260

Description

@cboulay

Summary

ezmsg.core.settingsmeta._sanitize checks isinstance(value, (bool, int, float, str)) before it checks isinstance(value, enum.Enum). IntEnum and StrEnum members are instances of int/str, so they short-circuit on the first branch and are returned verbatim, un-sanitized.

Those values then travel to the graph server and out over the settings event / snapshot / metadata wires as pickled enum members. Any subscriber that does not have the defining package installed cannot pickle.loads them, and the whole subscription dies with ModuleNotFoundError.

Where

ezmsg/core/settingsmeta.py (v3.9.0, and current dev):

def _sanitize(value: Any) -> Any:
    if value is None or isinstance(value, (bool, int, float, str)):
        return value                     # <-- IntEnum / StrEnum returned as-is
    if isinstance(value, enum.Enum):
        return _sanitize(value.value)    # <-- unreachable for IntEnum / StrEnum
    ...

Reproduction

import enum
import ezmsg.core as ez
from ezmsg.core.settingsmeta import settings_structured_value, settings_schema_from_type

class Rate(enum.IntEnum):
    FAST = 5

class Mode(enum.StrEnum):
    A = "a"

class Plain(enum.Enum):
    X = "x"

class S(ez.Settings):
    rate: Rate = Rate.FAST
    mode: Mode = Mode.A
    plain: Plain = Plain.X

print(settings_structured_value(S()))
# {'rate': <Rate.FAST: 5>, 'mode': <Mode.A: 'a'>, 'plain': 'x'}
#          ^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^ leaked          ^^^ correctly sanitized

print([(f.name, f.default) for f in settings_schema_from_type(S).fields])
# [('rate', <Rate.FAST: 5>), ('mode', <Mode.A: 'a'>), ('plain', 'x')]

Affected wires

_sanitize is the sanitizer for everything settings-shaped that leaves the process, so the leak reaches all of these:

  • SettingsSnapshotValue.structured_value and .repr_valuebackendprocess.py::_settings_snapshot_value
  • SettingsFieldMetadata.default and .choicessettings_schema_from_type, hence ComponentMetadata.settings_schema in GraphMetadata
  • and therefore SettingsChangedEvent on GraphContext.subscribe_settings_events(), GraphContext.settings_snapshot(), and the component metadata a client reads back from the graph server

Observed behaviour

Our experiment-control UI runs in its own venv and deliberately does not depend on the pipelines' acquisition stack. Its settings-event forwarder died on every reconnect:

File "ezmsg/core/graphcontext.py", line 581, in _subscribe_pickled_stream
    value = pickle.loads(payload)
ModuleNotFoundError: No module named 'pycbsdk'

The setting in question is ezmsg.blackrock.CereLinkSignalSettings.subscribe_rate: SampleRate, where pycbsdk.session.SampleRate is an IntEnum.

Two things make this worse than a single dropped message:

  1. It wedges permanently. The subscriber reconnects with after_seq=0, which replays the graph server's retained event history — including the event it cannot decode. So one un-decodable retained event kills settings observation for the whole life of the graph server, not for one message. _subscribe_pickled_stream has no per-payload recovery: a failed pickle.loads propagates out of the generator and tears down the subscription.
  2. The information was never needed. _sanitize's entire job is to reduce settings to JSON-safe scalars so that observers need none of the graph's dependencies. Reordering the two checks yields {'rate': 5, 'mode': 'a', 'plain': 'x'} — a pycbsdk-free payload with no loss of anything a consumer uses (json.dumps already renders an IntEnum as its integer, so rendered output is unchanged).

Suggested fix

Move the enum.Enum check above the primitives check:

def _sanitize(value: Any) -> Any:
    if isinstance(value, enum.Enum):
        return _sanitize(value.value)
    if value is None or isinstance(value, (bool, int, float, str)):
        return value
    ...

This is generic — it covers IntEnum, StrEnum, IntFlag, and any other mixin-enum from any dependency, not just this one.

Happy to open a PR with the change plus a regression test asserting _sanitize leaves no non-builtin types in its output for an IntEnum/StrEnum-valued Settings.

Possibly worth considering separately

  • _subscribe_pickled_stream could survive a bad payload — log and skip rather than terminate the subscription. Even with the sanitizer fixed, the wire is raw pickle, so the next accidental leak reproduces the same permanent wedge in a client that is doing nothing wrong.
  • These wires arguably need not be pickle at all. After this fix, SettingsChangedEvent / TopologyChangedEvent / ProfilingTraceStreamBatch contain only JSON-representable data plus UUID, enum, tuple and one opaque bytes field (SettingsSnapshotValue.serialized, which nothing in the tree ever unpickles — ezmsg-dashboard only reports serialized is not None). A JSON envelope would make the cross-venv contract structural instead of conventional. Related: GraphServer pickles snapshots under the global command lock, stalling a starting graph #257.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions