[PATCH 12/26] lucid: snapshot shared state under task_lock in display
Daniel Gomez <[email protected]> Tue, 19 May 2026 15:28:09 +0200
| Newsgroups | dev.linux.lists.kdevops |
|---|---|
| Message-ID | <[email protected]> |
From: Daniel Gomez <[email protected]> The display thread and the main Ansible thread share a broader set of mutable attributes than task_lock currently guards: the current task name, the task host list, the deque of completed tasks, the spinner index, the last-update timestamp, the current play name, and the pending play header. Previously only running_tasks was protected, so a redraw could read current_task_name already bumped to the new task while current_task_hosts still referenced the previous task's hosts, or iterate completed_tasks while the main thread was appending a fresh entry. Take task_lock once at the top of _redraw_display and copy every field the rest of the method will read into local variables, then release before doing any terminal I/O. The redraw now operates on a consistent snapshot and the lock is never held across a stdout write. On the writer side, wrap the current_task_name and current_task_hosts assignments in v2_playbook_on_task_start, fold the current_task_hosts append in v2_runner_on_start into the same critical section that already guards running_tasks, protect the play-name and pending-play-header assignments in v2_playbook_on_play_start, make _flush_play_header's check-and- clear atomic, and extend the existing lock in _handle_result to cover the completed_tasks append. The failed_items reset in _display_failed_items is also serialized so task_start and the failure-display path cannot race. Suggested-by: Chuck Lever <[email protected]> Generated-by: Claude AI Signed-off-by: Daniel Gomez <[email protected]> --- callback_plugins/lucid.py | 87 +++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/callback_plugins/lucid.py b/callback_plugins/lucid.py index 371376b3..cdce984f 100644 --- a/callback_plugins/lucid.py +++ b/callback_plugins/lucid.py @@ -427,16 +427,18 @@ class CallbackModule(CallbackBase): hosts_str = str(hosts) msg = f"\nPLAY: {name} [{hosts_str}]" - self.current_play_name = f"PLAY: {name} [{hosts_str}]" - self.pending_play_header = msg + with self.task_lock: + self.current_play_name = f"PLAY: {name} [{hosts_str}]" + self.pending_play_header = msg self._write_to_log(msg) def _flush_play_header(self): """Print deferred play header on first task of the play""" - if self.pending_play_header: - if not self.dynamic_mode: - self._display_message(self.pending_play_header, C.COLOR_HIGHLIGHT) + with self.task_lock: + header = self.pending_play_header self.pending_play_header = None + if header and not self.dynamic_mode: + self._display_message(header, C.COLOR_HIGHLIGHT) def v2_playbook_on_task_start(self, task, is_conditional): """Task started""" @@ -451,17 +453,18 @@ class CallbackModule(CallbackBase): return self._flush_play_header() - self.current_task_name = task_name - self.failed_items = [] - # Initialize with play hosts so display is stable from the start - self.current_task_hosts = list(self.play_hosts) if self.play_hosts else [] + with self.task_lock: + self.current_task_name = task_name + self.failed_items = [] + # Initialize with play hosts so display is stable from the start + self.current_task_hosts = list(self.play_hosts) if self.play_hosts else [] # In static mode, print immediately (compact - no leading newline) if not self.dynamic_mode: - msg = f"TASK: {self.current_task_name}" + msg = f"TASK: {task_name}" self._display_message(msg, C.COLOR_HIGHLIGHT) - self._write_to_log(f"TASK: {self.current_task_name}") + self._write_to_log(f"TASK: {task_name}") def v2_runner_on_start(self, host, task): """Task started on a host (for dynamic tracking)""" @@ -477,9 +480,8 @@ class CallbackModule(CallbackBase): "delegate_to": delegate_to, "task_name": task.get_name().strip(), } - - if host.name not in self.current_task_hosts: - self.current_task_hosts.append(host.name) + if host.name not in self.current_task_hosts: + self.current_task_hosts.append(host.name) if self.dynamic_mode: self._redraw_display() @@ -628,7 +630,8 @@ class CallbackModule(CallbackBase): } # Add to completed tasks (last 3 for dynamic mode) - self.completed_tasks.append(result_data) + with self.task_lock: + self.completed_tasks.append(result_data) # Log everything (max verbosity) self._log_result(result, status, duration) @@ -795,14 +798,23 @@ class CallbackModule(CallbackBase): def _redraw_display(self): """Redraw entire display in dynamic mode""" - # Throttle updates (max once per 0.1s) - now = time.time() - if now - self.last_update < 0.1: - return - self.last_update = now - - # Increment spinner - self.spinner_index = (self.spinner_index + 1) % len(self.SPINNER_FRAMES) + # Snapshot all shared state under task_lock for a consistent frame + with self.task_lock: + now = time.time() + if now - self.last_update < 0.1: + return + self.last_update = now + + self.spinner_index = (self.spinner_index + 1) % len(self.SPINNER_FRAMES) + spinner_idx = self.spinner_index + + task_name = self.current_task_name + task_hosts = list(self.current_task_hosts) + completed = list(self.completed_tasks) + play_name = self.current_play_name + running_hosts = { + host: dict(info) for (host, _), info in self.running_tasks.items() + } # Clear previous display self._clear_display() @@ -814,35 +826,29 @@ class CallbackModule(CallbackBase): lines = [] # Play header - if self.current_play_name: - lines.append(self._truncate_line(self.current_play_name, term_width)) + if play_name: + lines.append(self._truncate_line(play_name, term_width)) # Task header - truncate to terminal width - if self.current_task_name: - task_line = f"TASK: {self.current_task_name}" + if task_name: + task_line = f"TASK: {task_name}" lines.append(self._truncate_line(task_line, term_width)) lines.append("") # Host status - show ALL hosts in task, running ones get spinner - # Create snapshot of running tasks under lock - with self.task_lock: - running_hosts = { - host: info for (host, _), info in self.running_tasks.items() - } - - if self.current_task_hosts: + if task_hosts: running_count = len(running_hosts) - total_hosts = len(self.current_task_hosts) + total_hosts = len(task_hosts) lines.append(f"Hosts: {running_count}/{total_hosts} running") - spinner = self.SPINNER_FRAMES[self.spinner_index] + spinner = self.SPINNER_FRAMES[spinner_idx] # Show all hosts in stable order, running ones with spinner # Format: [spinner] <time> <hostname> - time is fixed width (8 chars) # This keeps spinner and time columns stable, hostname varies at end time_width = 8 # Enough for "1m 30s", "10h 5m", or "99d 23h" - for host in self.current_task_hosts: + for host in task_hosts: if host in running_hosts: # Running - show spinner, time, then hostname task_info = running_hosts[host] @@ -877,9 +883,9 @@ class CallbackModule(CallbackBase): lines.append("") # Recently completed (after running tasks) - if self.completed_tasks: + if completed: lines.append("Recent:") - for task_data in self.completed_tasks: + for task_data in completed: status = task_data["status"] host = task_data["host"] duration = task_data["duration"] @@ -936,7 +942,8 @@ class CallbackModule(CallbackBase): 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 = [] + with self.task_lock: + self.failed_items = [] def _freeze_and_show_output(self, result_data): """ -- 2.53.0