-
-
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 = Nonedef 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 threadCurrently: 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
💡 Found this wiki useful?
⭐ Star the repo
·
💖 Sponsor this project
·
📦 Latest release
·
🐞 Report an issue