-
-
Notifications
You must be signed in to change notification settings - Fork 34.2k
bpo-25942: Added new parameter to subprocess.run(): cleanup_timeout #4283
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d16a2e6
bebd8d3
ff62b7b
f7280e0
e0d3cbe
d1a4dac
b831801
22b030a
2e990ea
b797161
220b40f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -255,22 +255,23 @@ def _args_from_interpreter_flags(): | |
| return args | ||
|
|
||
|
|
||
| def call(*popenargs, timeout=None, **kwargs): | ||
| def call(*popenargs, timeout=None, cleanup_timeout=None, **kwargs): | ||
| """Run command with arguments. Wait for command to complete or | ||
| timeout, then return the returncode attribute. | ||
|
|
||
| The arguments are the same as for the Popen constructor. Example: | ||
| This is equivalent to: | ||
|
|
||
| retcode = call(["ls", "-l"]) | ||
| """ | ||
| with Popen(*popenargs, **kwargs) as p: | ||
| try: | ||
| return p.wait(timeout=timeout) | ||
| except: | ||
| p.kill() | ||
| p.wait() | ||
| raise | ||
| run(...).returncode | ||
|
|
||
| (except that the input and check parameters are not supported). | ||
|
|
||
| See run() for argument details. | ||
|
|
||
| Example: | ||
| retcode = call(["ls", "-l"]) | ||
| """ | ||
| return run(*popenargs, timeout=timeout, cleanup_timeout=cleanup_timeout, | ||
| **kwargs).returncode | ||
|
|
||
| def check_call(*popenargs, **kwargs): | ||
| """Run command with arguments. Wait for command to complete. If | ||
|
|
@@ -371,7 +372,7 @@ def check_returncode(self): | |
| self.stderr) | ||
|
|
||
|
|
||
| def run(*popenargs, input=None, timeout=None, check=False, **kwargs): | ||
| def run(*popenargs, input=None, timeout=None, check=False, cleanup_timeout=None, **kwargs): | ||
| """Run command with arguments and return a CompletedProcess instance. | ||
|
|
||
| The returned instance will have attributes args, returncode, stdout and | ||
|
|
@@ -386,6 +387,17 @@ def run(*popenargs, input=None, timeout=None, check=False, **kwargs): | |
| If timeout is given, and the process takes too long, a TimeoutExpired | ||
| exception will be raised. | ||
|
|
||
| If the current process encounters any exception (e.g. KeyboardInterrupt) and | ||
| cleanup_timeout is non-zero, the child process is given some additional | ||
| time to finish before it is killed, to allow potential clean-up | ||
| operations in the child to complete. During this time, a second exception | ||
| will kill the child immediately. | ||
|
|
||
| If cleanup_timeout is not specified, then a default value is chosen: | ||
| If timeout was provided, then any remaining time from the original timeout | ||
| is used. Otherwise, if the exception is a KeyboardInterrupt, the child is | ||
| given 1 second to clean up. Otherwise, the child is killed immediately. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If cleanup timeout is not specified, there should be no cleanup timeout.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I assume you chose that phrasing to be pointedly simple, but, unfortunately, I think your phrasing is not as simple as it appears. In the I think you meant to propose: "If cleanup timeout is not specified, there should be a cleanup timeout of 0.0".
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @stuarteberg I agree that "no cleanup timeout" is not clear enough, but it is not the same as cleanup timeout of 0 seconds. How about: If kill_timeout is not specified, the child process will be killed immediately when the timeout expire. If specified, the child process will be terminated gracefully using SIGTERM. If the process did not terminate within kill_timeout seconds, it will be killed. This should be modified if graceful termination should be the default behavior. I'm not sure what should be the default, but this can be left for another PR. |
||
|
|
||
| There is an optional argument "input", allowing you to | ||
| pass bytes or a string to the subprocess's stdin. If you use this argument | ||
| you may not also use the Popen constructor's "stdin" argument, as | ||
|
|
@@ -404,6 +416,8 @@ def run(*popenargs, input=None, timeout=None, check=False, **kwargs): | |
| raise ValueError('stdin and input arguments may not both be used.') | ||
| kwargs['stdin'] = PIPE | ||
|
|
||
| start_time = _time() | ||
|
|
||
| with Popen(*popenargs, **kwargs) as process: | ||
| try: | ||
| stdout, stderr = process.communicate(input, timeout=timeout) | ||
|
|
@@ -412,10 +426,53 @@ def run(*popenargs, input=None, timeout=None, check=False, **kwargs): | |
| stdout, stderr = process.communicate() | ||
| raise TimeoutExpired(process.args, timeout, output=stdout, | ||
| stderr=stderr) | ||
| except: | ||
| process.kill() | ||
| process.wait() | ||
| raise | ||
| except BaseException as ex: | ||
| try: | ||
| if cleanup_timeout is None: | ||
| # Choose a reasonable default | ||
| if timeout: | ||
| remaining_timeout = timeout - (_time() - start_time) | ||
| cleanup_timeout = max(0.0, remaining_timeout) | ||
| elif isinstance(ex, KeyboardInterrupt): | ||
| # KeyboardInterrupts are: | ||
| # 1. Typically automatically triggered in the child, too. | ||
| # Therefore, the child is likely to be already cleaning up | ||
| # (if we give it the chance). | ||
| # 2. Generated interactively by humans at a keyboard. | ||
| # If the child is well-written (exits in response to SIGINT), | ||
| # a 1 second timeout is usually enough time for it to clean up. | ||
| # Othwerwise, 1 second seems like a tolerable delay in response to Ctrl+C, | ||
| # and a resonable price to pay for correct behavior in the well-written case. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You cannot assume that a child will get SIGINT, this is true only for a foreground process in the shell. The parent process can be a daemon and nobody will generate SIGINT for its children.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Disclaimer: I cannot claim to be an expert in POSIX conventions, so forgive me if you have expertise in this area and know the following to be incorrect.) If I understand correctly, the primary distinction between It is reasonable to assume that receiving a IMHO, programmers who would send a On the other hand, a default cleanup timeout of 0.0 would mean that the child is killed prematurely, and the user experiences incorrect/incomplete cleanup behavior when hitting But what about a default cleanup delay of 1.0 second? If the child responds to
The idea with this PR is to help Python programmers write code that "just works" in the default case. After all, the (BTW, in Python 2, the behavior wasn't perfect, but it happened to be better-behaved in this one instance.)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Question: After thinking about your comment some more, I'm now wondering: What do you think about explicitly sending There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should TERM (or, worse, KILL) it, not INTerrupt it. Firstly, it is reasonable to assume the child already got the INT. More importantly, a TERM better fits the longstanding semantic conventions of signal use here. While it's true that a SIGINT could be generated by some circumstance other than a terminal driver catching Ctrl-C and signaling the whole foreground process group (not individual PIDs), in practice that would be an exceedingly rare violation of longstanding convention. POSIX refers to SIGINT as the "terminal interrupt signal", for example. Indeed, the fact that python by default turns a SIGINT into a KeyboardInterrupt tells you something about its expected use. I don't see benefit in second-guessing system behavior when an INT rolls in. If we're going to terminate the child, TERM the child — and please let devs like me suppress that behavior entirely. :)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Command lines tools are used both interactively and run by other programs. They are expected to handle both SIGINT and SIGTERM, and typically both are handled in the same way. We can assume that a child process is handling both signals. If you run a program that needs special treatment, use Popen directly instead of the high level easy to use call() or run().
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I completely agree that they should. But as Python programmers, it would be particularly odd for us to assume that they do. After all, tools written in Python itself will respond differently for For example, on a whim, I just checked a few popular utilities that are implemented in Python. Frankly, these results shocked me:
Again, I completely agree regarding what the proper POSIX conventions ought to be. But now we have to decide: we can either be POSIX purists, or be considerate to the typical user. I can see arguments in favor of either, but we should acknowledge the trade-off we're making.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The subprocess module is not the place to fix bad signal handling in other programs. Maybe send a PR to httpie/pip?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 for making a PR to those projects. But of course there will always be others with the same issue, and we can't fix all of them. I'm just wondering if we have an opportunity to apply the Zen of Python maxim: "practicality beats purity". I'm honestly not sure. But doing the most "friendly" thing (if we can figure out what that is) seems spiritually compatible with a convenience API like
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We cannot fix the issue in subprocess. For example we can have a correct program that block SIGINT when running without a controlling terminal. The only way to terminate the program is SIGTERM. We must support such program by sending SIGTERM. If another program misbehaves by not handling SIGTERM, the program author should fix the program. |
||
| cleanup_timeout = 1.0 | ||
| else: | ||
| # The above reasoning does not apply to arbitrary exceptions, | ||
| # so kill the child immediately. | ||
| cleanup_timeout = 0.0 | ||
|
|
||
| if cleanup_timeout == 0.0: | ||
| raise # Skip cleanup | ||
|
|
||
| # Close streams to/from child | ||
| streams = (process.stdin, process.stdout, process.stderr) | ||
| for f in filter(None, streams): | ||
| try: | ||
| f.close() | ||
| except: | ||
| pass # Ignore EBADF or other errors. | ||
|
|
||
| try: | ||
| process.wait(timeout=cleanup_timeout) | ||
| except: | ||
| # Ignore cleanup errors; | ||
| # original exception is always re-raised below | ||
| pass | ||
| finally: | ||
| # Kill the child and re-raise original exception, | ||
| # regardless of successful/failed cleanup | ||
| process.kill() | ||
| process.wait() | ||
| raise ex | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we need much simple solution. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. Something like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Regardless of the default timeout value, I do like this proposal to explicitly One open question is whether or not it is bad to send a For example, if the child is a Python program, then the default behavior of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From the perspective of the child process, a TERM is at worst equivalent to a KILL, and possibly much more polite, since it can be caught. So, if we are committed to killing the child process, I'd encourage a TERM first. (It's tempting to full-on generalize this and allow something like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
👍
Agree with the sentiment. I also completely agree with what you implied by using the word "tempting" (instead of, say, "justified" 😄 ). The |
||
| retcode = process.poll() | ||
| if check and retcode: | ||
| raise CalledProcessError(retcode, process.args, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Added new parameter, ``cleanup_timeout``, to ``subprocess.run()`` | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. kill_timeout may be more clear, the time we give child process to terminate before we kill it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am ambivalent regarding the parameter name. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Too complicated.