-
-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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
┌─────────────────┐
│ 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() │
└─────────────────┘
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)
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:
- Create
QThreadobject - Create
BuildWorker(QObject) -
Move worker to thread with
moveToThread() - Connect signals/slots
- Start thread with
thread.start()
@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)
@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()
@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)
How to build first, then run if successful?
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:
- User presses F5 (Build & Run)
- Set
_pending_run_after_build = True - Start async build
- Build completes →
on_build_finished() - Check flag, if true → call
run_current() -
run_current()uses non-blockingPopen(already async)
# 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
# Worker auto-deletes worker.finished.connect(worker.deleteLater) # Thread auto-deletes thread.finished.connect(thread.deleteLater) # Reference cleared self.current_build_thread = None
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)
def _setup_widgets(self): # ... # Add build status label to status bar self.statusBuildLabel = QLabel("Ready") self.statusbar.addPermanentWidget(self.statusBuildLabel)
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)
Controlled by user settings:
if self.settings.show_build_elapsed: msg += f" in {result.elapsed_ms:.0f} ms"
[x] No freezing during compilation
[x] User can resize/move windows
[x] Menus remain accessible
[x] Professional user experience
[x] Real-time status updates
[x] Build timing visible
[x] Clear success/failure indication
[x] Elapsed time transparency
[x] Cannot start multiple builds
[x] Warns before closing during build
[x] Threads properly cleaned up
[x] Exception handling in worker
[x] Build & Run works seamlessly
[x] Can queue Run after Build
[x] Non-blocking Run (Popen)
[x] Build/Run buttons disabled during build
- Thread creation: ~10ms (one-time)
- Signal emission: <1ms (negligible)
- UI updates: ~5ms (batched by Qt)
- Total overhead: ~15ms (vs 1000-3000ms build time = <2%)
-
QThread: ~50KB per thread -
BuildWorker: ~10KB - Only 1 thread active at a time
- Auto-cleanup prevents leaks
| 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%) |
- Open large project
- Press F7 (Build)
- During build:
- Try resizing window [x]
- Try opening menus [x]
- Try starting another build (should be blocked) [x]
- Check status bar shows "Building..." [x]
- After build, check timing appears [x]
# 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
# Old PyQt style - NOT RECOMMENDED class BuildThread(QThread): def run(self): result = build_project(...)
Problem: Tight coupling, harder to test
# Modern PyQt6 style - RECOMMENDED class BuildWorker(QObject): def run(self): result = build_project(...) worker.moveToThread(thread)
Benefit: Loose coupling, easier to test
# WRONG - crashes or undefined behavior def run(self): self.main_window.statusLabel.setText("Building...") # ❌
# CORRECT - thread-safe def run(self): self.started.emit() # [x] Signal triggers UI update in main thread
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())
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()
Currently: Indeterminate "Building..." message
Future: Progress percentage for multi-file projects
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