Skip to content

Asynchronous Build System

techy4shri edited this page Nov 29, 2025 · 2 revisions

Asynchronous Build System

CppLab IDE uses a fully asynchronous build system to ensure the UI remains responsive during compilation. This document explains the architecture and implementation details.

Problem Statement

The Freezing Problem

Traditional IDEs often execute builds synchronously:

# ❌ Blocking approach (old method)
def on_build_project(self):
    result = build_project(config, toolchains)  # BLOCKS for 1-3 seconds
    update_ui(result)

Issues:

  • UI freezes during compilation (1-3 seconds)
  • User cannot interact with menus/windows
  • Feels unresponsive and unprofessional
  • Cannot cancel or monitor progress

Solution: Threading with Qt

Architecture

┌─────────────────┐
│   MainWindow    │ ← Main Thread (Qt Event Loop)
│                 │
│  build_current()│
└────────┬────────┘
         │ creates
         ↓
┌─────────────────┐
│    QThread      │ ← Background Thread
│                 │
│  BuildWorker    │
│    .run()       │
└────────┬────────┘
         │ calls
         ↓
┌─────────────────┐
│   builder.py    │ ← Core Build Logic
│                 │
│ build_project() │
│ build_single_   │
│   _file()       │
└─────────────────┘

Implementation

1. BuildWorker Class

Location: src/cpplab/app.py

class BuildWorker(QObject):
    """Worker that runs build/check operations in a background thread."""
    
    # Signals (thread-safe communication)
    started = pyqtSignal()
    finished = pyqtSignal(object)  # BuildResult
    error = pyqtSignal(str)
    
    def __init__(self, toolchains, project_config=None, source_path=None,
                 force_rebuild=False, check_only=False):
        super().__init__()
        self.toolchains = toolchains
        self.project_config = project_config
        self.source_path = source_path
        self.force_rebuild = force_rebuild
        self.check_only = check_only
    
    @pyqtSlot()
    def run(self):
        """Execute the build/check operation."""
        try:
            self.started.emit()
            
            # Determine operation
            if self.check_only:
                if self.project_config:
                    result = check_project(self.project_config, self.toolchains)
                else:
                    result = check_single_file(self.source_path, self.toolchains)
            else:
                if self.project_config:
                    result = build_project(self.project_config, 
                                          self.toolchains, 
                                          self.force_rebuild)
                else:
                    result = build_single_file(self.source_path, self.toolchains)
            
            self.finished.emit(result)
            
        except Exception as e:
            self.error.emit(str(e))

Key Points:

  • Inherits QObject (not QThread) - modern PyQt6 pattern
  • Uses Qt signals for thread-safe communication
  • @pyqtSlot() decorator for proper slot connection
  • Handles both project and standalone builds
  • Supports syntax-only checks (check_only=True)

2. Thread Management in MainWindow

Location: src/cpplab/app.py

def start_build_task(self, *, project_config=None, source_path=None,
                     force_rebuild=False, check_only=False):
    """Start a background build/check task if none is running."""
    
    # Prevent concurrent builds
    if self.build_in_progress:
        QMessageBox.information(self, "Build In Progress", 
            "A build is already running. Please wait for it to complete.")
        return
    
    # Save files before building
    if not check_only:
        self.on_save_all()
    
    # Create thread and worker
    thread = QThread(self)
    worker = BuildWorker(
        toolchains=self.toolchains,
        project_config=project_config,
        source_path=source_path,
        force_rebuild=force_rebuild,
        check_only=check_only
    )
    
    # Move worker to thread (critical!)
    worker.moveToThread(thread)
    
    # Connect signals
    thread.started.connect(worker.run)              # Start work when thread starts
    worker.started.connect(self.on_build_started)   # Update UI
    worker.finished.connect(self.on_build_finished) # Handle result
    worker.error.connect(self.on_build_error)       # Handle errors
    worker.finished.connect(thread.quit)            # Stop thread
    worker.finished.connect(worker.deleteLater)     # Clean up worker
    thread.finished.connect(thread.deleteLater)     # Clean up thread
    
    # Store reference and start
    self.current_build_thread = thread
    self.build_in_progress = True
    thread.start()

Thread Safety Pattern:

  1. Create QThread object
  2. Create BuildWorker (QObject)
  3. Move worker to thread with moveToThread()
  4. Connect signals/slots
  5. Start thread with thread.start()

3. Signal Handlers

Build Started

@pyqtSlot()
def on_build_started(self):
    """Handle build start - update UI to show build in progress."""
    self.statusBuildLabel.setText("Building...")
    
    # Disable actions to prevent spam
    self.buildProjectAction.setEnabled(False)
    self.buildAndRunAction.setEnabled(False)
    self.runProjectAction.setEnabled(False)
    
    # Clear output and switch to Build tab
    self.output_panel.clear_output()
    self.output_panel.append_output("=== Build Started ===\n")
    self.outputDockWidget.setVisible(True)
    self.outputTabWidget.setCurrentIndex(0)

Build Finished

@pyqtSlot(object)
def on_build_finished(self, result: BuildResult):
    """Handle build completion - update UI with results."""
    # Re-enable actions
    self.buildProjectAction.setEnabled(True)
    self.buildAndRunAction.setEnabled(True)
    self.runProjectAction.setEnabled(True)
    self.build_in_progress = False
    self.current_build_thread = None
    
    # Display output
    if result.command:
        self.output_panel.append_output(f"\nCommand: {' '.join(result.command)}\n")
    
    if result.stdout:
        self.output_panel.append_output("\n--- Standard Output ---\n")
        self.output_panel.append_output(result.stdout)
    
    if result.stderr:
        self.output_panel.append_output("\n--- Standard Error ---\n")
        self.output_panel.append_output(result.stderr)
    
    # Update status bar with timing
    if result.success:
        msg = "Build succeeded"
    else:
        msg = "Build failed"
    
    if hasattr(result, "elapsed_ms") and self.settings.show_build_elapsed:
        msg += f" in {result.elapsed_ms:.0f} ms"
    
    if result.skipped:
        msg = "Build skipped (up to date)"
    
    self.statusBuildLabel.setText(msg)
    
    # Handle Build & Run workflow
    if result.success and self._pending_run_after_build:
        self._pending_run_after_build = False
        self.run_current()

Build Error

@pyqtSlot(str)
def on_build_error(self, message: str):
    """Handle build error."""
    self.build_in_progress = False
    self.current_build_thread = None
    
    # Re-enable actions
    self.buildProjectAction.setEnabled(True)
    self.buildAndRunAction.setEnabled(True)
    self.runProjectAction.setEnabled(True)
    
    self.statusBuildLabel.setText("Build error")
    QMessageBox.critical(self, "Build Error", message)

Build & Run Workflow

Challenge

How to build first, then run if successful?

Solution: Pending Flag

def on_build_and_run(self):
    """Build and run current project or standalone file."""
    self._pending_run_after_build = True
    self.build_current()

# Later in on_build_finished:
if result.success and self._pending_run_after_build:
    self._pending_run_after_build = False
    self.run_current()

Flow:

  1. User presses F5 (Build & Run)
  2. Set _pending_run_after_build = True
  3. Start async build
  4. Build completes → on_build_finished()
  5. Check flag, if true → call run_current()
  6. run_current() uses non-blocking Popen (already async)

Concurrency Control

Preventing Parallel Builds

# State flag
self.build_in_progress: bool = False

# Check before starting
if self.build_in_progress:
    QMessageBox.information(self, "Build In Progress", 
        "A build is already running. Please wait for it to complete.")
    return

# Set flag
self.build_in_progress = True
thread.start()

# Clear flag when done
def on_build_finished(self, result):
    self.build_in_progress = False

Thread Cleanup

# Worker auto-deletes
worker.finished.connect(worker.deleteLater)

# Thread auto-deletes
thread.finished.connect(thread.deleteLater)

# Reference cleared
self.current_build_thread = None

Application Exit

def closeEvent(self, event):
    """Handle application close event."""
    if self.build_in_progress:
        reply = QMessageBox.question(
            self, "Build in progress",
            "A build is currently running. Do you really want to exit?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No
        )
        if reply == QMessageBox.StandardButton.No:
            event.ignore()
            return
    
    super().closeEvent(event)

Status Bar Integration

Setup

def _setup_widgets(self):
    # ...
    # Add build status label to status bar
    self.statusBuildLabel = QLabel("Ready")
    self.statusbar.addPermanentWidget(self.statusBuildLabel)

Status Updates

Ready
  ↓ (build starts)
Building...
  ↓ (build completes)
Build succeeded in 1234 ms
  ↓ (or if failed)
Build failed in 567 ms
  ↓ (or if skipped)
Build skipped (up to date)

Timing Display

Controlled by user settings:

if self.settings.show_build_elapsed:
    msg += f" in {result.elapsed_ms:.0f} ms"

Benefits

1. Responsive UI

[x] No freezing during compilation
[x] User can resize/move windows
[x] Menus remain accessible
[x] Professional user experience

2. Build Feedback

[x] Real-time status updates
[x] Build timing visible
[x] Clear success/failure indication
[x] Elapsed time transparency

3. Safety

[x] Cannot start multiple builds
[x] Warns before closing during build
[x] Threads properly cleaned up
[x] Exception handling in worker

4. Workflow

[x] Build & Run works seamlessly
[x] Can queue Run after Build
[x] Non-blocking Run (Popen)
[x] Build/Run buttons disabled during build

Performance Impact

Thread Overhead

  • Thread creation: ~10ms (one-time)
  • Signal emission: <1ms (negligible)
  • UI updates: ~5ms (batched by Qt)
  • Total overhead: ~15ms (vs 1000-3000ms build time = <2%)

Memory

  • QThread: ~50KB per thread
  • BuildWorker: ~10KB
  • Only 1 thread active at a time
  • Auto-cleanup prevents leaks

Comparison

Aspect Synchronous Asynchronous
UI Responsiveness ❌ Freezes 1-3s [x] Always responsive
User Experience Poor Professional
Build Timing Hidden Visible in status
Concurrent Builds Possible (bad) Prevented (good)
Code Complexity Simple Moderate
Thread Overhead None ~15ms (<2%)

Testing

Manual Testing

  1. Open large project
  2. Press F7 (Build)
  3. During build:
    • Try resizing window [x]
    • Try opening menus [x]
    • Try starting another build (should be blocked) [x]
  4. Check status bar shows "Building..." [x]
  5. After build, check timing appears [x]

Unit Testing

# Future: Test signal emissions
def test_build_worker_signals():
    worker = BuildWorker(toolchains, project_config=config)
    started_emitted = False
    finished_emitted = False
    
    def on_started():
        nonlocal started_emitted
        started_emitted = True
    
    def on_finished(result):
        nonlocal finished_emitted
        finished_emitted = True
        assert result.success
    
    worker.started.connect(on_started)
    worker.finished.connect(on_finished)
    worker.run()
    
    assert started_emitted
    assert finished_emitted

Common Pitfalls (Avoided)

❌ Subclassing QThread

# Old PyQt style - NOT RECOMMENDED
class BuildThread(QThread):
    def run(self):
        result = build_project(...)

Problem: Tight coupling, harder to test

[x] QObject + moveToThread

# Modern PyQt6 style - RECOMMENDED
class BuildWorker(QObject):
    def run(self):
        result = build_project(...)

worker.moveToThread(thread)

Benefit: Loose coupling, easier to test

❌ Direct UI Updates from Thread

# WRONG - crashes or undefined behavior
def run(self):
    self.main_window.statusLabel.setText("Building...")  # ❌

[x] Signal-Based Updates

# CORRECT - thread-safe
def run(self):
    self.started.emit()  # [x] Signal triggers UI update in main thread

Future Enhancements

Streaming Output

Currently: Output displayed after build completes
Future: Stream stdout/stderr line-by-line during build

# Future: Real-time output
class BuildWorker(QObject):
    output_line = pyqtSignal(str)  # Emit each line
    
    def run(self):
        process = subprocess.Popen(cmd, stdout=PIPE, ...)
        for line in process.stdout:
            self.output_line.emit(line.decode())

Build Cancellation

Currently: Build runs to completion
Future: Cancel button to terminate build

# Future: Cancellable builds
class BuildWorker(QObject):
    def __init__(self):
        self._cancelled = False
    
    def cancel(self):
        self._cancelled = True
        if self._process:
            self._process.terminate()

Progress Bar

Currently: Indeterminate "Building..." message
Future: Progress percentage for multi-file projects

Build Queue

Builds now run in parallel, subject to a configurable global concurrency limit (Settings → Build → Max concurrent builds). Independent projects/files are built concurrently while builds targeting the same project are serialized to avoid race conditions and artifact conflicts. Each concurrent build gets its own output entry and can be cancelled individually; the queue enforces per-project ordering and global throttling.


Next: Build System Details
Previous: Architecture Overview

Clone this wiki locally