Repository: ethereum/web3.py

Issue:
Suspicion that `@reject_recursive_repeats` is not thread-safe
* Version: 3.16.2
* Python: 3.6
* OS: osx


### What was wrong?

When executing a few contract calls concurrently, I kept getting errors like:

```
File "/usr/local/lib/python3.6/site-packages/web3/contract.py", line 870, in call_contract_function
    normalized_data = map_abi_data(normalizers, output_types, output_data)
  File "/usr/local/lib/python3.6/site-packages/eth_utils/string.py", line 85, in inner
    return force_obj_to_text(fn(*args, **kwargs))
  File "/usr/local/lib/python3.6/site-packages/web3/utils/abi.py", line 437, in map_abi_data
    return pipe(data, *pipeline)
  File "cytoolz/functoolz.pyx", line 586, in cytoolz.functoolz.pipe (cytoolz/functoolz.c:10663)
  File "cytoolz/functoolz.pyx", line 562, in cytoolz.functoolz.c_pipe (cytoolz/functoolz.c:10494)
  File "/usr/local/lib/python3.6/site-packages/web3/utils/decorators.py", line 31, in wrapped
    wrapped_val = to_wrap(*args)
  File "/usr/local/lib/python3.6/site-packages/web3/utils/formatters.py", line 117, in recursive_map
    items_mapped = map_collection(recurse, data)
  File "/usr/local/lib/python3.6/site-packages/web3/utils/formatters.py", line 104, in map_collection
    return datatype(map(func, collection))
  File "/usr/local/lib/python3.6/site-packages/web3/utils/formatters.py", line 116, in recurse
    return recursive_map(func, item)
  File "/usr/local/lib/python3.6/site-packages/web3/utils/decorators.py", line 29, in wrapped
    raise ValueError('Recursively called %s with %r' % (to_wrap, args))
ValueError: Recursively called <function recursive_map at 0x7f012ea030d0> with (<function strip_abi_type at 0x7f012ea06ae8>, ABITypedData(abi_type='address', data='0x168910909606A2Fca90D4C28Fa39b50407b9C526'))
```

and

```
  File "/usr/local/lib/python3.6/site-packages/web3/contract.py", line 870, in call_contract_function
    normalized_data = map_abi_data(normalizers, output_types, output_data)
  File "/usr/local/lib/python3.6/site-packages/eth_utils/string.py", line 85, in inner
    return force_obj_to_text(fn(*args, **kwargs))
  File "/usr/local/lib/python3.6/site-packages/web3/utils/abi.py", line 437, in map_abi_data
    return pipe(data, *pipeline)
  File "cytoolz/functoolz.pyx", line 586, in cytoolz.functoolz.pipe (cytoolz/functoolz.c:10663)
  File "cytoolz/functoolz.pyx", line 562, in cytoolz.functoolz.c_pipe (cytoolz/functoolz.c:10494)
  File "/usr/local/lib/python3.6/site-packages/web3/utils/decorators.py", line 29, in wrapped
    raise ValueError('Recursively called %s with %r' % (to_wrap, args))
ValueError: Recursively called <function recursive_map at 0x7f012ea030d0> with (<function strip_abi_type at 0x7f012ea06ae8>, [ABITypedData(abi_type='uint256', data=1000000000000000000), ABITypedData(abi_type='address', data='0xC66eA802717bFb9833400264Dd12c2bCeAa34a6d'), ABITypedData(abi_type='uint256', data=100000000000000000000), ABITypedData(abi_type='address', data='0xECF8F87f810EcF450940c9f60066b4a7a501d6A7'), ABITypedData(abi_type='address', data='0xbe69Be9133DAA77AeAFcA0d6330c7Ba44f597b15'), ABITypedData(abi_type='bool', data=True), ABITypedData(abi_type='uint64', data=1505245070)])
```

We are connecting to a Parity node.

### How can it be fixed?

My suspicion is that because `@reject_recursive_repeats` keeps state in `to_wrap.__already_called`, this is not thread-safe and will fail if two threads want to parse the same data at the same time.



Discussion:
> My suspicion is that because @reject_recursive_repeats keeps state in to_wrap.__already_called, this is not thread-safe and will fail if two threads want to parse the same data at the same time.

Yup, that looks plausible.

Because this decorator is a runtime assertion that should never be violated, the assertion acts as a helpful hint for debugging issues, rather than as a critical runtime feature. (only the assertion is buggy here, not the actual recursive call).

As a short term workaround, you could just remove the assertion by removing the decorator at: https://github.com/pipermerriam/web3.py/blob/v3/web3/utils/formatters.py#L109 Of course that means futzing with the source code of your installed web3. So depending on how urgent your need is, you might just prefer to wait for the next stable release. I would expect that we can get this done and released sometime by end of week. (hopefully sooner)

Resolving patch:
diff --git a/web3/utils/compat/compat_gevent.py b/web3/utils/compat/compat_gevent.py
--- a/web3/utils/compat/compat_gevent.py
+++ b/web3/utils/compat/compat_gevent.py
@@ -5,6 +5,7 @@
     WSGIServer,
 )
 from gevent import (  # noqa: F401
+    getcurrent,
     subprocess,
     socket,
     threading,
@@ -25,6 +26,19 @@
 GreenletThread = gevent.Greenlet
 
 
+class ClassicThread(object):
+    def __init__(self, threadid):
+        self.ident = threadid
+
+
+def get_current_thread():
+    threadid = id(getcurrent())
+    return ClassicThread(threadid)
+
+
+threading.current_thread = get_current_thread
+
+
 class Timeout(gevent.Timeout):
     def check(self):
         pass
diff --git a/web3/utils/compat/compat_stdlib.py b/web3/utils/compat/compat_stdlib.py
--- a/web3/utils/compat/compat_stdlib.py
+++ b/web3/utils/compat/compat_stdlib.py
@@ -100,8 +100,8 @@ def get(self, timeout=None):
             raise RuntimeError("Something went wrong.  No `_return` property was set")
 
 
-def spawn(target, thread_class=ThreadWithReturn, *args, **kwargs):
-    thread = thread_class(
+def spawn(target, *args, **kwargs):
+    thread = ThreadWithReturn(
         target=target,
         args=args,
         kwargs=kwargs,
diff --git a/web3/utils/decorators.py b/web3/utils/decorators.py
--- a/web3/utils/decorators.py
+++ b/web3/utils/decorators.py
@@ -1,6 +1,10 @@
 import functools
 import warnings
 
+from web3.utils.compat import (
+    threading,
+)
+
 
 class combomethod(object):
     def __init__(self, method):
@@ -24,12 +28,14 @@ def reject_recursive_repeats(to_wrap):
 
     @functools.wraps(to_wrap)
     def wrapped(*args):
-        instances = tuple(map(id, args))
-        if instances in to_wrap.__already_called:
+        arg_instances = tuple(map(id, args))
+        thread_id = threading.current_thread().ident
+        thread_local_args = (thread_id,) + arg_instances
+        if thread_local_args in to_wrap.__already_called:
             raise ValueError('Recursively called %s with %r' % (to_wrap, args))
-        to_wrap.__already_called[instances] = True
+        to_wrap.__already_called[thread_local_args] = True
         wrapped_val = to_wrap(*args)
-        del to_wrap.__already_called[instances]
+        del to_wrap.__already_called[thread_local_args]
         return wrapped_val
     return wrapped
 


Test patch:
diff --git a/tests/core/compat-utils/test_spawn.py b/tests/core/compat-utils/test_spawn.py
--- a/tests/core/compat-utils/test_spawn.py
+++ b/tests/core/compat-utils/test_spawn.py
@@ -1,13 +1,8 @@
 from web3.utils.compat.compat_stdlib import (
     spawn,
-    ThreadWithReturn,
 )
 
 
-class CustomThreadClass(ThreadWithReturn):
-    pass
-
-
 def test_spawning_simple_thread():
     container = {
         'success': None,
@@ -20,19 +15,3 @@ def target_fn():
     thread.join()
 
     assert container['success'] is True
-
-
-def test_spawning_specific_thread_class():
-    container = {
-        'success': None,
-    }
-
-    def target_fn():
-        container['success'] = True
-
-    thread = spawn(target_fn, thread_class=CustomThreadClass)
-    thread.join()
-
-    assert isinstance(thread, CustomThreadClass)
-
-    assert container['success'] is True
diff --git a/tests/core/utilities/test_decorators.py b/tests/core/utilities/test_decorators.py
new file mode 100644
--- /dev/null
+++ b/tests/core/utilities/test_decorators.py
@@ -0,0 +1,21 @@
+from web3.utils.compat import (
+    sleep,
+    spawn,
+)
+from web3.utils.decorators import (
+    reject_recursive_repeats,
+)
+
+
+def test_reject_recursive_repeats_multithreaded():
+    @reject_recursive_repeats
+    def recurse(sleep_now):
+        sleep(sleep_now)
+        try:
+            recurse(0.05)
+            return True
+        except ValueError:
+            return False
+    thd1 = spawn(recurse, 0)
+    thd2 = spawn(recurse, 0.02)
+    assert thd2.get() and thd1.get()