Repository navigation
[MOD-19365] Bound the default Redis shutdown wait #259
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
2abbff6
18a04d0
2ffaacc
1f7b82a
304834d
3938c1c
ef02f7d
14345f0
21c01ba
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 |
|---|---|---|
|
|
@@ -16,6 +16,10 @@ | |
| MASTER = 'master' | ||
| SLAVE = 'slave' | ||
|
|
||
| _TERMINATE_TIMEOUT = 30 | ||
| _INSTRUMENTED_TERMINATE_TIMEOUT = 300 | ||
| _KILL_TIMEOUT = 5 | ||
|
|
||
|
|
||
| class StandardEnv(object): | ||
| def __init__(self, redisBinaryPath, port=6379, modulePath=None, moduleArgs=None, outputFilesFormat=None, | ||
|
|
@@ -51,6 +55,7 @@ def __init__(self, redisBinaryPath, port=6379, modulePath=None, moduleArgs=None, | |
| self.masterProcess = None | ||
| self.masterStdout = None | ||
| self.masterStderr = None | ||
| self.shutdownFailed = False | ||
|
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. Never reset. With
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. Addressed attribution in 304834d, but did not reset the flag in startEnv: doing that could erase a forced-shutdown failure when the test immediately restarts Redis. Instead, _runTest records and consumes the failure under the originating test name at the end of that test, before reuse changes the name. Final teardown also consumes the flag when reporting it. Cluster aggregation visits every shard so resetting cannot short-circuit after the first failure. A regression uses two failed shards and a following test in the reused environment: only the original test is marked failed, all flags are consumed, and final teardown does not blame the following test. |
||
| self.masterExitCode = None | ||
| self.slaveProcess = None | ||
| self.slaveStdout = None | ||
|
|
@@ -463,27 +468,54 @@ def _stopProcess(self, role): | |
| self.verbose_analyse_server_log(role) | ||
| return | ||
| try: | ||
| if platform.system() == 'Darwin': | ||
| # On macOS, with lldb, killing lldb process does not terminate inferior processes | ||
| p0 = psutil.Process(pid=process.pid) | ||
| pchi = p0.children(recursive=True) | ||
| for p in pchi: | ||
| if platform.system() == 'Darwin' and self.has_interactive_debugger: | ||
|
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. Note: narrowing this to interactive debuggers is fine for plain macOS runs. But in an lldb session still at a breakpoint during teardown (e.g. debugging module shutdown), the inferior now gets SIGKILLed after 30s and the test is marked failed. Previously it waited for the user. Worth a line in the PR description.
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. Documented in README and the PR description in 304834d. On macOS, debugger-child teardown allows 30 seconds after SIGTERM and then 5 seconds after SIGKILL. An inferior left paused at a breakpoint can now be killed and reported as a failure; teardown no longer waits indefinitely for debugger input. The existing bounded-debugger-wait regression remains passing. |
||
| # lldb does not forward termination to its inferiors. Bound the | ||
| # whole child group wait, rather than waiting per child forever. | ||
| children = psutil.Process(process.pid).children(recursive=True) | ||
| for child in children: | ||
| try: | ||
| p.terminate() | ||
| p.wait() | ||
| except: | ||
| child.terminate() | ||
| except psutil.NoSuchProcess: | ||
| pass | ||
| _, alive = psutil.wait_procs(children, timeout=_TERMINATE_TIMEOUT) | ||
| if alive: | ||
| self.shutdownFailed = True | ||
| for child in alive: | ||
| try: | ||
| child.kill() | ||
| except psutil.NoSuchProcess: | ||
| pass | ||
| _, alive = psutil.wait_procs(alive, timeout=_KILL_TIMEOUT) | ||
| if alive: | ||
| print(Colors.Bred('[TERMINATING] debugger children survived SIGKILL')) | ||
|
|
||
| if self.terminateRetries is None: | ||
| # ask once, then wait for process to exit | ||
| process.terminate() | ||
| termination_start_time = time.time() | ||
| while process.poll() is None: # None returns if the processes is not finished yet, retry until redis exits | ||
| time.sleep(0.1) | ||
| if time.time() - termination_start_time > 30: | ||
| # if process is still running after 30 seconds, try reading its output | ||
| process_out, process_err = process.communicate() | ||
| print(Colors.Bred(f'\t[TERMINATING] out ({process_out}), error ({process_err})')) | ||
| # Wait on the process, not pipe EOF: Redis fork children can | ||
| # inherit stdout/stderr and outlive their parent. | ||
| grace = (_INSTRUMENTED_TERMINATE_TIMEOUT | ||
| if self.sanitizer or (self.debugger and not self.has_interactive_debugger) | ||
| else _TERMINATE_TIMEOUT) | ||
| deadline = time.monotonic() + grace | ||
| while process.poll() is None: | ||
| process.terminate() | ||
| remaining = deadline - time.monotonic() | ||
| if remaining <= 0: | ||
| break | ||
| try: | ||
| process.wait(timeout=min(1, remaining)) | ||
| except subprocess.TimeoutExpired: | ||
| # Redis may refuse shutdown during initial AOF rewrite; | ||
| # retry SIGTERM so it can exit cleanly after the rewrite. | ||
| continue | ||
| if process.poll() is None: | ||
| self.shutdownFailed = True | ||
| print(Colors.Bred('[TERMINATING] {0} server id {1} did not exit on SIGTERM; sending SIGKILL'.format(role, serverId))) | ||
| process.kill() | ||
| try: | ||
| process.wait(timeout=_KILL_TIMEOUT) | ||
| except subprocess.TimeoutExpired: | ||
| print(Colors.Bred('[TERMINATING] {0} server id {1} did not exit after SIGKILL'.format(role, serverId))) | ||
| self._print_shutdown_log(role) | ||
| else: | ||
| # keep asking every few seconds until process has exited, otherwise kill | ||
| if self.terminateRetrySecs is None: | ||
|
|
@@ -508,6 +540,24 @@ def _stopProcess(self, role): | |
| 'OSError caught while waiting for {0} process to end: {1}'.format(role, e.__str__()))) | ||
| pass | ||
|
|
||
| def hasShutdownFailure(self, reset=False): | ||
| failed = self.shutdownFailed | ||
| if reset: | ||
| self.shutdownFailed = False | ||
| return failed | ||
|
|
||
| def _print_shutdown_log(self, role): | ||
| try: | ||
| path = os.path.join(self.dbDirPath or '', self._getFileName(role, '.log')) | ||
| with open(path, 'rb') as log: | ||
| log.seek(0, os.SEEK_END) | ||
| log.seek(max(0, log.tell() - 8192)) | ||
| print(Colors.Bred('[TERMINATING] last server log bytes ({0}):\n{1}'.format( | ||
| path, log.read(8192).decode('utf-8', errors='replace')))) | ||
| except Exception as error: | ||
| # Diagnostics must never interrupt teardown, including invalid paths. | ||
| print(Colors.Bred('[TERMINATING] could not read server log: {0}'.format(error))) | ||
|
|
||
| def verbose_analyse_server_log(self, role): | ||
| path = "{0}".format(self._getFileName(role, '.log')) | ||
| if self.dbDirPath is not None: | ||
|
|
||
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.
Class results print late or vanish
Medium Severity
EnvScopeGuardnow holds every[PASS]and[SKIP]until the guard exits, but one guard wraps an entire test class. Failures still print immediately, so later method[FAIL]lines appear before earlier passes, and a forced class teardown sets_teardownFailedand drops queued passes for methods that already succeeded. Function tests are unaffected because each has its own guard.Additional Locations (2)
RLTest/__main__.py#L850-L869RLTest/__main__.py#L892-L937Reviewed by Cursor Bugbot for commit 21c01ba. Configure here.