DEV Community

Cover image for Fixing a Frozen UI & a Sneaky Scheduler Crash — A Tale of Threads, Signals, and Defensive Code 🧵🔥
Rolan Lobo
Rolan Lobo

Posted on

Fixing a Frozen UI & a Sneaky Scheduler Crash — A Tale of Threads, Signals, and Defensive Code 🧵🔥

If your app looks alive but feels dead… congratulations 🎉

You’ve probably blocked the main thread.

This post is a walkthrough of two real bugs I fixed recently:

  1. A UI freeze caused by blocking file I/O on the main thread
  2. A nasty crash where a scheduler ran before it was initialized

Both bugs were invisible during “small tests” and brutally obvious in real usage.

Let’s break down what went wrong, how I fixed it, and what I learned.


🧊 Problem #1: The UI Freeze from Hell

Symptoms

  • Clicking Organize Files froze the entire UI
  • Large file sets (50+ files) caused 5+ seconds of silence
  • No progress bar movement
  • Users thought the app had crashed (fair assumption)

Root Cause

I was doing exactly what every GUI app should never do:

Running file operations directly on the main GUI thread

Here’s the original code 😬

# Preview mode - BLOCKING code
temp_file_ops = FileOperations(dest_dir, "Organized Files", dry_run=True)
temp_file_ops.start_operations()

# 🚨 This loop blocks the UI
for file_path in self.selected_files:
    category_path = temp_file_ops.categorize_file(file_path)
    temp_file_ops.move_file(file_path, category_path)

temp_file_ops.finalize_operations()
Enter fullscreen mode Exit fullscreen mode

That innocent-looking for loop?
Yeah… that was freezing everything.


✅ The Fix: Move Work Off the Main Thread

Instead of doing file I/O directly, I refactored preview mode to use the same background threading system already working for real file moves.

New Approach (Non-Blocking)

# Preview mode - NON-BLOCKING code
self.preview_file_ops = FileOperations(dest_dir, "Organized Files", dry_run=True)

self.processing_thread = ProcessingThread(
    self.selected_files,
    self.preview_file_ops
)

self.processing_thread.progress.connect(self.update_progress)
self.processing_thread.finished.connect(
    lambda: self._on_preview_finished(dest_dir)
)
self.processing_thread.error.connect(self.on_processing_error)

self.processing_thread.start()
Enter fullscreen mode Exit fullscreen mode

Why This Works

  • File processing runs in a background thread
  • UI stays responsive
  • Progress bar updates in real time
  • Errors are handled safely via signals

✨ Chef’s kiss.


🪄 Showing the Preview After the Work Is Done

Instead of showing the preview dialog immediately, I added a new handler that fires only after the background thread finishes:

def _on_preview_finished(self, dest_dir):
    self.progress_bar.setVisible(False)

    if self.preview_file_ops.dry_run_manager.has_operations():
        dialog = PreviewDialog(
            self.preview_file_ops.dry_run_manager,
            self
        )

        if dialog.exec() == QDialog.DialogCode.Accepted:
            self._execute_organization(dest_dir)
        else:
            self.status_bar.showMessage("Preview cancelled")
Enter fullscreen mode Exit fullscreen mode

Result:

  • No frozen UI
  • No half-ready preview
  • Clean, predictable flow

📊 Before vs After (Quick Visual)

❌ Before (Blocking)

Main Thread
 └── Loop over files
     └── UI freezes 😵
Enter fullscreen mode Exit fullscreen mode

✅ After (Non-Blocking)

Main Thread        Background Thread
 ├── UI alive 🟢    └── File processing
 ├── Progress bar  └── Categorization
 └── Signals       └── Dry-run tracking
Enter fullscreen mode Exit fullscreen mode

🧨 Problem #2: The Scheduler That Crashed on Startup

This one was sneakier.

The Crash

Sometimes the app would crash when:

  • Auto-sort was enabled before manual organization
  • Scheduled jobs ran on fresh app start
  • The app reopened with existing schedules

The Culprit

The scheduler was created like this:

self.scheduler = SortScheduler(None, self.categorizer)
Enter fullscreen mode Exit fullscreen mode

That None?
Yeah… file_ops wasn’t initialized yet.

So when the scheduler tried to move files:

💥 NoneType crash


🛡️ The Fix: Lazy Initialization + Defensive Guards

Step 1: Don’t Initialize Too Early

# Delay scheduler creation
self.scheduler = None
Enter fullscreen mode Exit fullscreen mode

Step 2: Centralize Setup with _ensure_file_ops()

if self.scheduler is None:
    self.scheduler = SortScheduler(self.file_ops, self.categorizer)
    self.scheduler.start()
else:
    self.scheduler.file_ops = self.file_ops
Enter fullscreen mode Exit fullscreen mode

Now:

  • Scheduler only exists when file_ops exists
  • No duplicated setup logic
  • No race conditions

🧯 Step 3: Defense in Depth (Crash Prevention)

Even with good initialization, I added guards inside the scheduler and watcher:

if self.file_ops is None:
    logging.error("FileOperations not initialized")
    return False
Enter fullscreen mode Exit fullscreen mode

This ensures:

  • No crashes
  • Errors are logged
  • App degrades gracefully instead of exploding

🎯 Final Results

Before

❌ Frozen UI
❌ No progress feedback
❌ Random crashes
❌ Users panic

After

✅ Fully responsive UI
✅ Smooth progress updates
✅ Safe background execution
✅ Stable scheduler & watcher
✅ App feels professional


🧪 Testing Still Matters

I manually tested:

  • Small file sets
  • Large file sets
  • Preview ON / OFF
  • Auto-sort before manual use
  • Scheduled jobs on restart

Everything behaved exactly as expected 🎉


🧠 Takeaways

  • Never block the GUI thread
  • Reuse proven threading patterns
  • Lazy initialization beats eager crashes
  • Defensive checks save your future self
  • If the UI freezes, users will assume the worst

If you’re building desktop apps with Python + Qt:
Threads + signals are not optional — they’re survival tools.

Happy coding 🧑‍💻🔥
And may your UI never freeze again.

Top comments (0)