commit 7fba50ff561578f746ceb3383b1bb16d709a14e3 Author: Damian Johnson atagar@torproject.org Date: Sat Dec 8 14:13:43 2012 -0800
Supporting TAKEOWNERSHIP when spawing a tor process
Adding a 'take_ownership' option to the stem.process module's launch_tor*() functions. When it's set we'll do the following...
* Assert ownership over the tor process that we spawn by setting its __OwningControllerProcess to be our pid.
* If a Controller connects to it then we replace our ownership via the pid with ownership via the socket connection (issuing a TAKEOWNERSHIP request and dropping __OwningControllerProcess).
Thanks to lunar for the initial TAKEOWNERSHIP patch on #7666, and rransom for his advice on #7667. --- stem/control.py | 20 +++++++++ stem/process.py | 43 ++++++++++++++++--- stem/version.py | 4 +- test/integ/process.py | 112 +++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 160 insertions(+), 19 deletions(-)
diff --git a/stem/control.py b/stem/control.py index 79ad70f..db5b9c5 100644 --- a/stem/control.py +++ b/stem/control.py @@ -110,6 +110,7 @@ providing its own for interacting at a higher level.
from __future__ import with_statement
+import os import time import Queue import StringIO @@ -1495,6 +1496,25 @@ class Controller(BaseController): self._attach_listeners() except stem.ProtocolError, exc: log.warn("We were unable to re-attach our event listeners to the new tor instance (%s)" % exc) + + # issue TAKEOWNERSHIP if we're the owning process for this tor instance + + owning_pid = self.get_conf("__OwningControllerProcess", None) + + if owning_pid == str(os.getpid()): + response = self.msg("TAKEOWNERSHIP") + stem.response.convert("SINGLELINE", response) + + if response.is_ok(): + # Now that tor is tracking our ownership of the process via the control + # connection, we can stop having it check for us via our pid. + + try: + self.reset_conf("__OwningControllerProcess") + except stem.ControllerError, exc: + log.warn("We were unable to reset tor's __OwningControllerProcess configuration. It will continue to periodically check if our pid exists. (%s)" % response) + else: + log.warn("We were unable assert ownership of tor through TAKEOWNERSHIP, despite being configured to be the owning process thrugh __OwningControllerProcess. (%s)" % response)
def _handle_event(self, event_message): stem.response.convert("EVENT", event_message, arrived_at = time.time()) diff --git a/stem/process.py b/stem/process.py index 25ff88a..d0523bc 100644 --- a/stem/process.py +++ b/stem/process.py @@ -29,7 +29,7 @@ import stem.util.system NO_TORRC = "<no torrc>" DEFAULT_INIT_TIMEOUT = 90
-def launch_tor(tor_cmd = "tor", args = None, torrc_path = None, completion_percent = 100, init_msg_handler = None, timeout = DEFAULT_INIT_TIMEOUT): +def launch_tor(tor_cmd = "tor", args = None, torrc_path = None, completion_percent = 100, init_msg_handler = None, timeout = DEFAULT_INIT_TIMEOUT, take_ownership = False): """ Initializes a tor process. This blocks until initialization completes or we error out. @@ -51,6 +51,9 @@ def launch_tor(tor_cmd = "tor", args = None, torrc_path = None, completion_perce tor's initialization stdout as we get it :param int timeout: time after which the attempt to start tor is aborted, no timeouts are applied if **None** + :param bool take_ownership: asserts ownership over the tor process so it + aborts if this python process terminates or a :class:`~stem.control.Controller` + we establish to it disconnects
:returns: **subprocess.Popen** instance for the tor subprocess
@@ -76,6 +79,9 @@ def launch_tor(tor_cmd = "tor", args = None, torrc_path = None, completion_perce else: runtime_args += ["-f", torrc_path]
+ if take_ownership: + runtime_args += ["--__OwningControllerProcess", _get_pid()] + tor_process = subprocess.Popen(runtime_args, stdout = subprocess.PIPE, stderr = subprocess.PIPE)
if timeout: @@ -140,13 +146,28 @@ def launch_tor(tor_cmd = "tor", args = None, torrc_path = None, completion_perce if ": " in msg: msg = msg.split(": ")[-1].strip() last_problem = msg
-def launch_tor_with_config(config, tor_cmd = "tor", completion_percent = 100, init_msg_handler = None, timeout = DEFAULT_INIT_TIMEOUT): +def launch_tor_with_config(config, tor_cmd = "tor", completion_percent = 100, init_msg_handler = None, timeout = DEFAULT_INIT_TIMEOUT, take_ownership = False): """ Initializes a tor process, like :func:`~stem.process.launch_tor`, but with a customized configuration. This writes a temporary torrc to disk, launches tor, then deletes the torrc.
- :param dict config: configuration options, such as '{"ControlPort": "9051"}' + For example... + + :: + + tor_process = stem.process.launch_tor_with_config( + config = { + 'ControlPort': '2778', + 'Log': [ + 'NOTICE stdout', + 'ERR file /tmp/tor_error_log', + ], + }, + ) + + :param dict config: configuration options, such as '{"ControlPort": "9051"}', + values can either be a **str** or **list of str** if for multiple values :param str tor_cmd: command for starting tor :param int completion_percent: percent of bootstrap completion at which this'll return @@ -154,6 +175,9 @@ def launch_tor_with_config(config, tor_cmd = "tor", completion_percent = 100, in tor's initialization stdout as we get it :param int timeout: time after which the attempt to start tor is aborted, no timeouts are applied if **None** + :param bool take_ownership: asserts ownership over the tor process so it + aborts if this python process terminates or a :class:`~stem.control.Controller` + we establish to it disconnects
:returns: **subprocess.Popen** instance for the tor subprocess
@@ -165,11 +189,18 @@ def launch_tor_with_config(config, tor_cmd = "tor", completion_percent = 100, in
try: with open(torrc_path, "w") as torrc_file: - for key, value in config.items(): - torrc_file.write("%s %s\n" % (key, value)) + for key, values in config.items(): + if isinstance(values, str): + torrc_file.write("%s %s\n" % (key, values)) + else: + for value in values: + torrc_file.write("%s %s\n" % (key, value))
- return launch_tor(tor_cmd, None, torrc_path, completion_percent, init_msg_handler, timeout) + return launch_tor(tor_cmd, None, torrc_path, completion_percent, init_msg_handler, timeout, take_ownership) finally: try: os.remove(torrc_path) except: pass
+def _get_pid(): + return str(os.getpid()) + diff --git a/stem/version.py b/stem/version.py index a7f31c3..243fc50 100644 --- a/stem/version.py +++ b/stem/version.py @@ -37,7 +37,8 @@ easily parsed and compared, for instance... **AUTH_SAFECOOKIE** 'SAFECOOKIE' authentication method **GETINFO_CONFIG_TEXT** 'GETINFO config-text' query **EXTENDCIRCUIT_PATH_OPTIONAL** 'EXTENDCIRCUIT' queries can omit the path if the circuit is zero - **LOADCONF** 'LOADCONF' query + **LOADCONF** 'LOADCONF' requests + **TAKEOWNERSHIP** 'TAKEOWNERSHIP' requests **TORRC_CONTROL_SOCKET** 'ControlSocket <path>' config option **TORRC_DISABLE_DEBUGGER_ATTACHMENT** 'DisableDebuggerAttachment' config option **FEATURE_VERBOSE_NAMES** 'VERBOSE_NAMES' optional feature @@ -257,6 +258,7 @@ Requirement = stem.util.enum.Enum( ("GETINFO_CONFIG_TEXT", Version("0.2.2.7")), ("EXTENDCIRCUIT_PATH_OPTIONAL", Version("0.2.2.9")), ("LOADCONF", Version("0.2.1.1")), + ("TAKEOWNERSHIP", Version("0.2.2.28-beta")), ("TORRC_CONTROL_SOCKET", Version("0.2.0.30")), ("TORRC_DISABLE_DEBUGGER_ATTACHMENT", Version("0.2.3.9")), ("FEATURE_VERBOSE_NAMES", Version("0.2.2.1-alpha")), diff --git a/test/integ/process.py b/test/integ/process.py index fc2825a..1c99f88 100644 --- a/test/integ/process.py +++ b/test/integ/process.py @@ -7,20 +7,35 @@ import time import shutil import signal import unittest +import subprocess
import stem.prereq import stem.socket import stem.process -import test.runner +import stem.version import stem.util.system
+import test.runner +from test import mocking + DATA_DIRECTORY = '/tmp/stem_integ'
+def _kill_process(process): + if stem.prereq.is_python_26(): + process.kill() + elif not stem.util.system.is_windows(): + os.kill(process.pid, signal.SIGTERM) + + process.communicate() # block until its definitely gone + class TestProcess(unittest.TestCase): def setUp(self): if not os.path.exists(DATA_DIRECTORY): os.makedirs(DATA_DIRECTORY)
+ def tearDown(self): + mocking.revert_mocking() + def test_launch_tor_with_config(self): """ Exercises launch_tor_with_config. @@ -57,17 +72,7 @@ class TestProcess(unittest.TestCase): self.assertEquals("ControlPort=2778", str(getconf_response)) finally: if control_socket: control_socket.close() - - if stem.prereq.is_python_26(): - tor_process.kill() - elif not stem.util.system.is_windows(): - os.kill(tor_process.pid, signal.SIGTERM) - - # On OSX, python 2.5 this kill call doesn't seem to block, causing our - # tor instance to linger and cause a port conflict with the following - # test. Giving it a moment to kill for realz. - - time.sleep(0.5) + _kill_process(tor_process)
def test_launch_tor_with_timeout(self): """ @@ -88,4 +93,87 @@ class TestProcess(unittest.TestCase):
if not (runtime > 2 and runtime < 3): self.fail("Test should have taken 2-3 seconds, took %i instead" % runtime) + + def test_take_ownership_via_pid(self): + """ + Checks that the tor process quits after we do if we set take_ownership. To + test this we spawn a process and trick tor into thinking that it is us. + """ + + if not stem.prereq.is_python_26() and stem.util.system.is_windows(): + test.runner.skip(self, "(unable to kill subprocesses)") + return + elif not stem.util.system.is_available("sleep"): + test.runner.skip(self, "('sleep' command is unavailable)") + return + elif test.runner.only_run_once(self, "test_take_ownership_via_pid"): return + elif test.runner.require_version(self, stem.version.Requirement.TAKEOWNERSHIP): return + + # Have os.getpid provide the pid of a process we can safely kill. I hate + # needing to a _get_pid() helper but after much head scratching I haven't + # been able to mock os.getpid() or posix.getpid(). + + sleep_process = subprocess.Popen(['sleep', '10']) + mocking.mock(stem.process._get_pid, mocking.return_value(str(sleep_process.pid))) + + tor_process = stem.process.launch_tor_with_config( + tor_cmd = test.runner.get_runner().get_tor_command(), + config = { + 'SocksPort': '2777', + 'ControlPort': '2778', + 'DataDirectory': DATA_DIRECTORY, + }, + completion_percent = 5, + take_ownership = True, + ) + + # Kill the sleep command. Tor should quit shortly after. + + _kill_process(sleep_process) + + # tor polls for the process every fifteen seconds so this may take a + # while... + + for seconds_waited in xrange(30): + if tor_process.poll() == 0: + return # tor exited + + time.sleep(1) + + self.fail("tor didn't quit after the process that owned it terminated") + + def test_take_ownership_via_controller(self): + """ + Checks that the tor process quits after the controller that owns it + connects, then disconnects.. + """ + + if test.runner.only_run_once(self, "test_take_ownership_via_controller"): return + elif test.runner.require_version(self, stem.version.Requirement.TAKEOWNERSHIP): return + + tor_process = stem.process.launch_tor_with_config( + tor_cmd = test.runner.get_runner().get_tor_command(), + config = { + 'SocksPort': '2777', + 'ControlPort': '2778', + 'DataDirectory': DATA_DIRECTORY, + }, + completion_percent = 5, + take_ownership = True, + ) + + # We're the controlling process. Just need to connect then disconnect. + + controller = stem.control.Controller.from_port(control_port = 2778) + controller.authenticate() + controller.close() + + # give tor a few seconds to quit + for seconds_waited in xrange(5): + if tor_process.poll() == 0: + return # tor exited + + time.sleep(1) + + self.fail("tor didn't quit after the controller that owned it disconnected")