Repository: ansible/ansible

Issue:
gather_timeout from ansible.cfg is ignored when running a playbook.
##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
Timeout Module

##### ANSIBLE VERSION
```
ansible 2.3.0.0
  config file = /home/maximus/work/server-recipes/ansible.cfg
  configured module search path = Default w/o overrides
  python version = 2.7.9 (default, Apr  2 2015, 15:33:21) [GCC 4.9.2]
```

##### CONFIGURATION
```
[defaults]
hostfile = hosts
remote_user = root
private_key_file = ~/.ssh/myne
host_key_checking = False
force_handlers = True
forks = 50
inventory = inventory
timeout = 10
gather_timeout = 300
```
##### OS / ENVIRONMENT
Ubuntu 15

##### SUMMARY
The variable `gather_timeout` is being ignored. This changed from Ansible 2.2 to 2.3 since after the upgrade some servers started to timeout. In order to debug I changed the ansible code to spit the following line by. Changing facts to.

```
GATHER_TIMEOUT=None

class TimeoutError(Exception):
    pass

def timeout(seconds=None, error_message="Timer expired"):
    if seconds is None:
        seconds = globals().get('GATHER_TIMEOUT') or 10
    def decorator(func):
        def _handle_timeout(signum, frame):
            raise TimeoutError(
                error_message +
                ' global timer %s, seconds %s' % (
                    globals().get('GATHER_TIMEOUT'),
                    seconds
                )
            )
```

Output:
```
fatal: [server.something.com]: FAILED! => {"changed": false, "cmd": "/bin/lsblk --list --noheadings --paths --output NAME,UUID --exclude 2", "failed": true, "msg": "Timer expired global timer 300, seconds 10", "rc": 257}
```
This means that on the instantiation of the decorator `globals().get('GATHER_TIMEOUT')` is either `None` or `10`. On a brief look I would say `None` and then it's changed to the configuration value. I would say here:

```
def get_all_facts(module):

    setup_options = dict(module_setup=True)

    # Retrieve module parameters
    gather_subset = module.params['gather_subset']

    global GATHER_TIMEOUT
    GATHER_TIMEOUT = module.params['gather_timeout']
```
Which at this point is to late since the decorator was timeout was already set to 10.

##### STEPS TO REPRODUCE
Set `gather_timeout` in ansible.cfg 

##### EXPECTED RESULTS
Expect timeout to match `gather_timeout` value.

##### ACTUAL RESULTS
Timeout is always 10.



Discussion:
Looks like the function get_mount_facts timed out.
Or probably it failed to run lsblk command. Could you run this command manually on the host?
```
/bin/lsblk --list --noheadings --paths --output NAME,UUID --exclude 2
```
It is a bug. The root cause is the variable from ansible.cfg didn't take effect, but the variable from command can:
```
bin/ansible localhost -m setup -a "gather_timeout=20"
```
Dup https://github.com/ansible/ansible/issues/17316
I believe @i1caro 's initial analysis is correct and this is a new bug.  Looking for a fix now.

Resolving patch:
diff --git a/lib/ansible/module_utils/facts.py b/lib/ansible/module_utils/facts.py
--- a/lib/ansible/module_utils/facts.py
+++ b/lib/ansible/module_utils/facts.py
@@ -77,22 +77,24 @@
 # steps do not exceed a time limit
 
 GATHER_TIMEOUT=None
+DEFAULT_GATHER_TIMEOUT = 10
 
 class TimeoutError(Exception):
     pass
 
 def timeout(seconds=None, error_message="Timer expired"):
 
-    if seconds is None:
-        seconds = globals().get('GATHER_TIMEOUT') or 10
-
     def decorator(func):
         def _handle_timeout(signum, frame):
             raise TimeoutError(error_message)
 
         def wrapper(*args, **kwargs):
+            local_seconds = seconds  # Make local var as we modify this every time it's invoked
+            if local_seconds is None:
+                local_seconds = globals().get('GATHER_TIMEOUT') or DEFAULT_GATHER_TIMEOUT
+
             signal.signal(signal.SIGALRM, _handle_timeout)
-            signal.alarm(seconds)
+            signal.alarm(local_seconds)
             try:
                 result = func(*args, **kwargs)
             finally:
@@ -103,11 +105,11 @@ def wrapper(*args, **kwargs):
 
     # If we were called as @timeout, then the first parameter will be the
     # function we are to wrap instead of the number of seconds.  Detect this
-    # and correct it by setting seconds to our default value and return the
+    # and correct it by setting seconds to our sentinel value and return the
     # inner decorator function manually wrapped around the function
     if callable(seconds):
         func = seconds
-        seconds = 10
+        seconds = None
         return decorator(func)
 
     # If we were called as @timeout([...]) then python itself will take


Test patch:
diff --git a/test/units/module_utils/facts/__init__.py b/test/units/module_utils/facts/__init__.py
new file mode 100644
diff --git a/test/units/module_utils/facts/test_timeout.py b/test/units/module_utils/facts/test_timeout.py
new file mode 100644
--- /dev/null
+++ b/test/units/module_utils/facts/test_timeout.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+# (c) 2017, Toshio Kuratomi <tkuratomi@ansible.com>
+#
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division)
+__metaclass__ = type
+
+import time
+
+import pytest
+
+from ansible.compat.tests import unittest
+from ansible.compat.tests.mock import patch, MagicMock
+
+from ansible.module_utils import facts
+
+
+@pytest.fixture
+def set_gather_timeout_higher():
+    default_timeout = facts.GATHER_TIMEOUT
+    facts.GATHER_TIMEOUT = facts.DEFAULT_GATHER_TIMEOUT + 5
+    yield
+    facts.GATHER_TIMEOUT = default_timeout
+
+
+@pytest.fixture
+def set_gather_timeout_lower():
+    default_timeout = facts.GATHER_TIMEOUT
+    facts.GATHER_TIMEOUT = 2
+    yield
+    facts.GATHER_TIMEOUT = default_timeout
+
+
+@facts.timeout
+def sleep_amount_implicit(amount):
+    # implicit refers to the lack of argument to the decorator
+    time.sleep(amount)
+    return 'Succeeded after {0} sec'.format(amount)
+
+
+@facts.timeout(facts.DEFAULT_GATHER_TIMEOUT + 5)
+def sleep_amount_explicit_higher(amount):
+    # explicit refers to the argument to the decorator
+    time.sleep(amount)
+    return 'Succeeded after {0} sec'.format(amount)
+
+
+@facts.timeout(2)
+def sleep_amount_explicit_lower(amount):
+    # explicit refers to the argument to the decorator
+    time.sleep(amount)
+    return 'Succeeded after {0} sec'.format(amount)
+
+
+def test_defaults_still_within_bounds():
+    # If the default changes outside of these bounds, some of the tests will
+    # no longer test the right thing.  Need to review and update the timeouts
+    # in the other tests if this fails
+    assert facts.DEFAULT_GATHER_TIMEOUT >= 4
+
+
+def test_implicit_file_default_succeeds():
+    # amount checked must be less than DEFAULT_GATHER_TIMEOUT
+    assert sleep_amount_implicit(1) == 'Succeeded after 1 sec'
+
+
+def test_implicit_file_default_timesout():
+    # sleep_time is greater than the default
+    sleep_time = facts.DEFAULT_GATHER_TIMEOUT + 1
+    with pytest.raises(facts.TimeoutError):
+        assert sleep_amount_implicit(sleep_time) == '(Not expected to succeed)'
+
+
+def test_implicit_file_overridden_succeeds(set_gather_timeout_higher):
+    # Set sleep_time greater than the default timeout and less than our new timeout
+    sleep_time = facts.DEFAULT_GATHER_TIMEOUT + 1
+    assert sleep_amount_implicit(sleep_time) == 'Succeeded after {0} sec'.format(sleep_time)
+
+
+def test_implicit_file_overridden_timesout(set_gather_timeout_lower):
+    # Set sleep_time greater than our new timeout but less than the default
+    sleep_time = 3
+    with pytest.raises(facts.TimeoutError):
+        assert sleep_amount_implicit(sleep_time) == '(Not expected to Succeed)'
+
+
+def test_explicit_succeeds():
+    # Set sleep_time greater than the default timeout and less than our new timeout
+    sleep_time = facts.DEFAULT_GATHER_TIMEOUT + 1
+    assert sleep_amount_explicit_higher(sleep_time) == 'Succeeded after {0} sec'.format(sleep_time)
+
+
+def test_explicit_timeout():
+    # Set sleep_time greater than our new timeout but less than the default
+    sleep_time = 3
+    with pytest.raises(facts.TimeoutError):
+        assert sleep_amount_explicit_lower(sleep_time) == '(Not expected to succeed)'