[PATCH 11/26] lucid: serialize display output with output_lock
Daniel Gomez <[email protected]> Tue, 19 May 2026 15:28:08 +0200
| Newsgroups | dev.linux.lists.kdevops |
|---|---|
| Message-ID | <[email protected]> |
From: Daniel Gomez <[email protected]> The background display thread uses raw sys.stdout.write for ANSI cursor control and live updates, while the main Ansible thread writes through self._display.display. The two paths have no mutual exclusion, and in dynamic mode they can fire concurrently while the redraw loop is halfway through emitting an escape sequence. The result is torn output: interleaved text, stranded cursor-movement codes that misalign subsequent draws, or display lines overwritten mid-render. Introduce an output_lock and acquire it around every write that reaches the user's terminal. This covers the _display_message helper, the early log-path banner, the per-task command line, the aggregate output block, the per-item failure loop, the redraw's final line emission, and the clear_display ANSI flush. The redraw and clear paths now build the full escape-sequence string first and emit it as a single write inside the lock so one acquisition covers one atomic terminal update. The two output mechanisms remain distinct (_display.display still goes through Ansible's formatting layer, and raw writes still bypass it for the live-update region). The lock just ensures they cannot interleave. Suggested-by: Chuck Lever <[email protected]> Generated-by: Claude AI Signed-off-by: Daniel Gomez <[email protected]> --- callback_plugins/lucid.py | 60 ++++++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/callback_plugins/lucid.py b/callback_plugins/lucid.py index 6e678932..371376b3 100644 --- a/callback_plugins/lucid.py +++ b/callback_plugins/lucid.py @@ -109,6 +109,7 @@ class CallbackModule(CallbackBase): self.update_thread: Optional[threading.Thread] = None self.update_thread_stop: Optional[threading.Event] = None self.task_lock = threading.Lock() + self.output_lock = threading.Lock() # Failed loop items for display on task failure self.failed_items: List[dict] = [] @@ -371,10 +372,11 @@ class CallbackModule(CallbackBase): def _display_message(self, message: str, color=None): """Display message with optional color""" - if color: - self._display.display(message, color=color) - else: - self._display.display(message) + with self.output_lock: + if color: + self._display.display(message, color=color) + else: + self._display.display(message) # ======================================================================== # Ansible v2 Callback Methods @@ -393,7 +395,8 @@ class CallbackModule(CallbackBase): # Show log file path early so user can tail -f if self.log_file_path: - self._display.display(f"Log: {self.log_file_path}") + with self.output_lock: + self._display.display(f"Log: {self.log_file_path}") def v2_playbook_on_play_start(self, play): """Play started""" @@ -681,7 +684,8 @@ class CallbackModule(CallbackBase): # Truncate very long commands if len(command) > 200: command = command[:197] + "..." - self._display.display(f" $ {command}", color=C.COLOR_VERBOSE) + with self.output_lock: + self._display.display(f" $ {command}", color=C.COLOR_VERBOSE) # Show output if conditions met if show_output: @@ -713,7 +717,8 @@ class CallbackModule(CallbackBase): output.append(f"\nEXCEPTION:\n{res['exception']}") if output: - self._display.display("".join(output)) + with self.output_lock: + self._display.display("".join(output)) def _log_result(self, result, status: str, duration: float): """Write result to log file (always max verbosity)""" @@ -895,12 +900,12 @@ class CallbackModule(CallbackBase): lines.append(self._truncate_line(recent_line, term_width)) lines.append("") - # Display all lines + # Display all lines atomically if lines: output = "\n".join(lines) - # Write directly to avoid extra newlines - sys.stdout.write(output) - sys.stdout.flush() + with self.output_lock: + sys.stdout.write(output) + sys.stdout.flush() # Count actual lines printed (number of newlines + 1 for the last line) self.display_lines = output.count("\n") + 1 else: @@ -909,27 +914,28 @@ class CallbackModule(CallbackBase): def _clear_display(self): """Clear dynamic display using ANSI escape codes""" if self.display_lines > 0: - # Clear current line first - sys.stdout.write("\r\033[2K") - # Move up and clear remaining lines + # Build full escape sequence then write atomically under lock + buf = "\r\033[2K" for _ in range(self.display_lines - 1): - sys.stdout.write("\033[1A") # Move cursor up one line - sys.stdout.write("\033[2K") # Clear entire line - sys.stdout.flush() + buf += "\033[1A\033[2K" + with self.output_lock: + sys.stdout.write(buf) + sys.stdout.flush() self.display_lines = 0 def _display_failed_items(self): """Display collected per-item failures and clear the list""" - for fi in self.failed_items: - label = fi["item"] - if fi["cmd"]: - self._display.display(f" [{label}] $ {fi['cmd']}", color=C.COLOR_VERBOSE) - if fi["stderr"]: - self._display.display(f" [{label}] stderr: {fi['stderr']}", color=C.COLOR_ERROR) - if fi["stdout"]: - self._display.display(f" [{label}] stdout: {fi['stdout']}") - if fi["msg"] and not fi["stdout"]: - self._display.display(f" [{label}] msg: {fi['msg']}", color=C.COLOR_ERROR) + with self.output_lock: + for fi in self.failed_items: + label = fi["item"] + if fi["cmd"]: + self._display.display(f" [{label}] $ {fi['cmd']}", color=C.COLOR_VERBOSE) + if fi["stderr"]: + self._display.display(f" [{label}] stderr: {fi['stderr']}", color=C.COLOR_ERROR) + if fi["stdout"]: + self._display.display(f" [{label}] stdout: {fi['stdout']}") + if fi["msg"] and not fi["stdout"]: + self._display.display(f" [{label}] msg: {fi['msg']}", color=C.COLOR_ERROR) self.failed_items = [] def _freeze_and_show_output(self, result_data): -- 2.53.0