diff --git a/newsfragments/4036.feature b/newsfragments/4036.feature new file mode 100644 index 000000000..36c062718 --- /dev/null +++ b/newsfragments/4036.feature @@ -0,0 +1 @@ +tahoe run now accepts --allow-stdin-close to mean "keep running if stdin closes" \ No newline at end of file diff --git a/src/allmydata/scripts/tahoe_run.py b/src/allmydata/scripts/tahoe_run.py index aaf234b61..ff3ff9efd 100644 --- a/src/allmydata/scripts/tahoe_run.py +++ b/src/allmydata/scripts/tahoe_run.py @@ -104,6 +104,11 @@ class RunOptions(BasedirOptions): " [default: %s]" % quote_local_unicode_path(_default_nodedir)), ] + optFlags = [ + ("allow-stdin-close", None, + 'Do not exit when stdin closes ("tahoe run" otherwise will exit).'), + ] + def parseArgs(self, basedir=None, *twistd_args): # This can't handle e.g. 'tahoe run --reactor=foo', since # '--reactor=foo' looks like an option to the tahoe subcommand, not to @@ -156,6 +161,7 @@ class DaemonizeTheRealService(Service, HookMixin): "running": None, } self.stderr = options.parent.stderr + self._close_on_stdin_close = False if options["allow-stdin-close"] else True def startService(self): @@ -199,10 +205,12 @@ class DaemonizeTheRealService(Service, HookMixin): d = service_factory() def created(srv): - srv.setServiceParent(self.parent) + if self.parent is not None: + srv.setServiceParent(self.parent) # exiting on stdin-closed facilitates cleanup when run # as a subprocess - on_stdin_close(reactor, reactor.stop) + if self._close_on_stdin_close: + on_stdin_close(reactor, reactor.stop) d.addCallback(created) d.addErrback(handle_config_error) d.addBoth(self._call_hook, 'running') @@ -213,11 +221,13 @@ class DaemonizeTheRealService(Service, HookMixin): class DaemonizeTahoeNodePlugin(object): tapname = "tahoenode" - def __init__(self, nodetype, basedir): + def __init__(self, nodetype, basedir, allow_stdin_close): self.nodetype = nodetype self.basedir = basedir + self.allow_stdin_close = allow_stdin_close def makeService(self, so): + so["allow-stdin-close"] = self.allow_stdin_close return DaemonizeTheRealService(self.nodetype, self.basedir, so) @@ -304,7 +314,9 @@ def run(reactor, config, runApp=twistd.runApp): print(config, file=err) print("tahoe %s: usage error from twistd: %s\n" % (config.subcommand_name, ue), file=err) return 1 - twistd_config.loadedPlugins = {"DaemonizeTahoeNode": DaemonizeTahoeNodePlugin(nodetype, basedir)} + twistd_config.loadedPlugins = { + "DaemonizeTahoeNode": DaemonizeTahoeNodePlugin(nodetype, basedir, config["allow-stdin-close"]) + } # our own pid-style file contains PID and process creation time pidfile = FilePath(get_pidfile(config['basedir'])) diff --git a/src/allmydata/test/cli/test_run.py b/src/allmydata/test/cli/test_run.py index e84f52096..2adcfea19 100644 --- a/src/allmydata/test/cli/test_run.py +++ b/src/allmydata/test/cli/test_run.py @@ -1,16 +1,8 @@ """ Tests for ``allmydata.scripts.tahoe_run``. - -Ported to Python 3. """ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals -from future.utils import PY2 -if PY2: - from future.builtins import filter, map, zip, ascii, chr, hex, input, next, oct, open, pow, round, super, bytes, dict, list, object, range, str, max, min # noqa: F401 +from __future__ import annotations import re from six.moves import ( @@ -31,6 +23,12 @@ from twisted.python.filepath import ( from twisted.internet.testing import ( MemoryReactor, ) +from twisted.python.failure import ( + Failure, +) +from twisted.internet.error import ( + ConnectionDone, +) from twisted.internet.test.modulehelpers import ( AlternateReactor, ) @@ -147,6 +145,91 @@ class DaemonizeTheRealServiceTests(SyncTestCase): ) +class DaemonizeStopTests(SyncTestCase): + """ + Tests relating to stopping the daemon + """ + def setUp(self): + self.nodedir = FilePath(self.mktemp()) + self.nodedir.makedirs() + config = "" + self.nodedir.child("tahoe.cfg").setContent(config.encode("ascii")) + self.nodedir.child("tahoe-client.tac").touch() + + # arrange to know when reactor.stop() is called + self.reactor = MemoryReactor() + self.stop_calls = [] + + def record_stop(): + self.stop_calls.append(object()) + self.reactor.stop = record_stop + + super().setUp() + + def _make_daemon(self, extra_argv: list[str]) -> DaemonizeTheRealService: + """ + Create the daemonization service. + + :param extra_argv: Extra arguments to pass between ``run`` and the + node path. + """ + options = parse_options(["run"] + extra_argv + [self.nodedir.path]) + options.stdout = StringIO() + options.stderr = StringIO() + options.stdin = StringIO() + run_options = options.subOptions + return DaemonizeTheRealService( + "client", + self.nodedir.path, + run_options, + ) + + def _run_daemon(self) -> None: + """ + Simulate starting up the reactor so the daemon plugin can do its + stuff. + """ + # We happen to know that the service uses reactor.callWhenRunning + # to schedule all its work (though I couldn't tell you *why*). + # Make sure those scheduled calls happen. + waiting = self.reactor.whenRunningHooks[:] + del self.reactor.whenRunningHooks[:] + for f, a, k in waiting: + f(*a, **k) + + def _close_stdin(self) -> None: + """ + Simulate closing the daemon plugin's stdin. + """ + # there should be a single reader: our StandardIO process + # reader for stdin. Simulate it closing. + for r in self.reactor.getReaders(): + r.connectionLost(Failure(ConnectionDone())) + + def test_stop_on_stdin_close(self): + """ + We stop when stdin is closed. + """ + with AlternateReactor(self.reactor): + service = self._make_daemon([]) + service.startService() + self._run_daemon() + self._close_stdin() + self.assertEqual(len(self.stop_calls), 1) + + def test_allow_stdin_close(self): + """ + If --allow-stdin-close is specified then closing stdin doesn't + stop the process + """ + with AlternateReactor(self.reactor): + service = self._make_daemon(["--allow-stdin-close"]) + service.startService() + self._run_daemon() + self._close_stdin() + self.assertEqual(self.stop_calls, []) + + class RunTests(SyncTestCase): """ Tests for ``run``.