Trigger event when CLI run has completed

I’ve been working on a slicer-skill to see if AI agents can help with slicer development. I put your question in and I got the answer below. I don’t know if it’ll fix your problem but it sounds about right.

<ai>

Root Cause Analysis: CLI StatusModifiedEvent Not Firing Until UI Interaction

This is a well-understood architectural issue in Slicer. Here’s exactly what’s happening and why.

The Status Update Chain

When a CLI module runs asynchronously via Apply(), status updates follow this path:

Background Thread (ApplyTask):
  node->SetStatus(Completing, false)   // flags InvokeStatusModifiedEvent=true, but NO Modified() call
  appLogic->RequestModified(node)      // pushes node onto a thread-safe queue
          |
          v
Main Thread Queue (InternalModifiedQueue):
  [node sits waiting in queue...]
          |
          v
QTimer::singleShot chain:             ← REQUIRES Qt EVENT LOOP TO BE SPINNING
  processAppLogicModified()
    → ProcessModified()
      → node->Modified()
        → StatusModifiedEvent          ← YOUR OBSERVER FIRES HERE

The critical link is step 3: ProcessModified() is triggered by a self-rescheduling QTimer::singleShot chain managed in qSlicerCoreApplication. These timers can only fire when Qt’s event loop is actively processing events.

The Key Question: Is This a Scripted CLI or Executable CLI?

If your CLI module is a scripted CLI module (Python-based, *.py with the CLI XML descriptor), this is almost certainly the cause:

  • Scripted CLI modules run in Slicer’s main thread due to Python’s GIL (Global Interpreter Lock). Even though Slicer tries to run them in a background thread, the GIL forces Python execution back onto the main thread.
  • While your torch + cv2 pipeline is running, it monopolizes the main thread. The Qt event loop cannot spin, so QTimer::singleShot callbacks never fire, and ProcessModified() is never called.
  • time.sleep() works because it releases the GIL, allowing the Qt event loop to process pending events during the sleep.

This is confirmed by Steve Pieper in a related discourse thread: “CLI modules are run in independent threads, while python code runs in the main thread, so that may be the difference.”

If your CLI module is an executable CLI (separate process), the computation itself shouldn’t block the main thread, but something else in your setup might be (e.g., synchronous I/O waiting, a blocking call in the module widget, etc.).

Why UI Interaction “Fixes” It

When you click in the Python console, switch modules, or perform any UI interaction, Qt processes pending events as part of handling that interaction. This finally allows the queued QTimer::singleShot to fire → ProcessModified() runs → Modified() is called → StatusModifiedEvent fires.

Solutions

Option 1: Convert to an executable CLI module (recommended for heavy computation)

Executable CLI modules run as a completely separate process, so they don’t block Slicer’s main thread or the Qt event loop. This is the cleanest solution for torch/cv2 pipelines.

Option 2: If you must use a scripted CLI, call slicer.app.processEvents() periodically

Insert calls to process the event loop during your pipeline:

import slicer

def run_long_pipeline():
    step1_result = step1()
    slicer.app.processEvents()  # let Qt process pending events
    
    step2_result = step2(step1_result)
    slicer.app.processEvents()
    
    # ... etc

This is a workaround, not ideal for production, but it unblocks the event loop during execution.

Option 3: Use slicer.cli.run() with wait_for_completion=False and an observer

If you’re launching the CLI from Python, the standard pattern is:

cliNode = slicer.cli.run(module, parameters, wait_for_completion=False)
cliNode.AddObserver('ModifiedEvent', onCLIStatusChanged)

But this still requires the event loop to be spinning in the main thread.

Option 4: Use QProcess or subprocess to run the heavy pipeline externally

Wrap your torch/cv2 pipeline as a standalone script that runs in a separate process via QProcess or Python’s subprocess, keeping the main thread free.

Relevant Discourse Threads

TL;DR

Your heavy Python pipeline (torch + cv2) is blocking Slicer’s main thread via the GIL, which prevents the Qt event loop from processing the QTimer::singleShot callbacks that deliver StatusModifiedEvent. time.sleep() works because it releases the GIL. The fix is to either convert to an executable CLI module or ensure the main thread’s event loop can spin during execution.
</ai>

1 Like