close
Skip to content

Deepcopy-based "safe" contexts can explode on rich objects #421

@bitprophet

Description

@bitprophet

Scenario

Pretty simple, just chuck things like compiled regexen (often found on their own, or within rich objects like boto2 clients) or thread locks (often found within database client connections, for example) into your config somewhere, then eagerly await some frustrating TypeErrors to bubble up from Python-core's copy.py.

E.g.:

  File "/Users/jforcier/Code/oss/invoke/invoke/config.py", line 534, in clone
    my_config = copy.deepcopy(self.config)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 163, in deepcopy
    y = copier(x, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 261, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 163, in deepcopy
    y = copier(x, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 261, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 194, in deepcopy
    y = _reconstruct(x, rv, 1, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 338, in _reconstruct
    state = deepcopy(state, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 163, in deepcopy
    y = copier(x, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 261, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 186, in deepcopy
    rv = reductor(2)
TypeError: can't pickle thread.lock objects

or (slightly different traceback and object type, though the reasoning is nearly identical):

  File "/Users/jforcier/Code/oss/invoke/invoke/config.py", line 534, in clone
    my_config = copy.deepcopy(self.config)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 163, in deepcopy
    y = copier(x, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 265, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 163, in deepcopy
    y = copier(x, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 265, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 198, in deepcopy
    y = _reconstruct(x, rv, 1, memo)
[... a good number more of this same repetition ...]
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 342, in _reconstruct
    state = deepcopy(state, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 163, in deepcopy
    y = copier(x, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 265, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 163, in deepcopy
    y = copier(x, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 306, in _deepcopy_inst
    state = deepcopy(state, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 163, in deepcopy
    y = copier(x, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 265, in _deepcopy_dict
    y[deepcopy(key, memo)] = deepcopy(value, memo)
  File "/usr/local/var/pyenv/versions/2.7.12/lib/python2.7/copy.py", line 175, in deepcopy
    y = copier(memo)
TypeError: cannot deepcopy this pattern object

Solutions / discussion

At its heart this is due to how we clone both configs and contexts in a few spots in Invoke's task execution stuff in order to prevent hard-to-debug unintentional state bleed between tasks, subroutines, etc. Cloning uses copy.deepcopy heavily since there's no other good way to copy arbitrary nested objects with unknown contents.

So what could we do to prevent/minimize these issues?

  • Straight up stop deepcopying (which would make 0.12 cannot use context for state handling #309 happy).
    • I still believe arbitrary mutation will lead to nasty hard-to-track bugs...
    • We legitimately need at least some cloning for things like parameterized tasks (think fab -H h1,h2,h3)
      • Though IIRC it's feasible to expect we could limit the cloning in that case to be far more specific; I forget the details.
    • But it would certainly fix this issue.
  • Attempt to deepcopy, and fallback to straight up regular copying or no copying, if we encounter TypeError during the deepcopy.
    • This feels real bad because it'd be silently inconsistent (even if it logs, it'll still confuse the hell out of people)
  • Attempt to write our own, "better" deepcopy (probably by extending config.merge_dicts) that crawls the entire tree and does a "regular" copy of leaf values instead of a wholesale deepcopy, and refuses to copy anything it doesn't understand.
    • Feels like reinventing deepcopy - we'd need to do everything it does (including attempting to call __deepcopy__ and such) with the sole difference being an attempt at "granular" TypeError exception handling.
    • But it would let us basically say "anything that isn't throwing a TypeError on deepcopy is getting copied, the rest are up to you", which is at least better than "there was a TypeError somewhere so you're now not getting any copying whatsoever"
      • Just...not actually a whole lot better, as it's still inconsistent.
  • Allow users to opt-out of the deepcopying/cloning when they encounter this issue & are willing to take on the burden of worrying about state bleed between tasks
    • Would also largely solve 0.12 cannot use context for state handling #309
    • I worry it would mean those corner cases where we really need cloning would then always break if this was turned on; or conversely that if we made them exempt, they'd still be enough to trigger the core issue here
  • Allow users to selectively opt-out of deepcopying specific keys (i.e. a blacklist of sorts)
    • Sidesteps the all-or-nothing nature of the other solutions
    • EDIT: this does require the "reimplement deepcopy, kinda" approach from above; clone() is currently implemented as "deepcopy all the cached source configs as well as the core config", it's merge() that uses merge_dicts. So it's still a moderate amount of work.
    • Less elegant / looks like an API/functionality wart
      • But it's not like there is an elegant solution to this besides "just never deepcopy, have fun debugging why nested task invocations are causing changes in behavior higher up the stack"
  • Ye olde "educate users" approach, instruct them to find other ways of passing around non-deepcopyable objects, or to (ugh) monkeypatch the objects in question so they either deepcopy happily or skip deepcopying explicitly.
    • Not great, obviously
    • But much less work, and less extra code on our end to develop its own bugs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions