Debugger-Aware Clock¶
Verified by tests
DebuggerAwareClockTests — library CI run #31657041675 (2026-08-13)
Overview¶
The Debugger-Aware Clock solves a common frustration during development: false timeout errors when debugging. When you hit a breakpoint, wall-clock time continues but execution is paused, causing timeouts across your system - perspective sync, transport layers, health checks, and more.
Whizbang provides a central clock service that tracks "active" time - time when code is actually executing - enabling timeouts that ignore time spent paused at breakpoints.
The Problem¶
The Problem
// Traditional timeout - triggers during debugging!
var stopwatch = Stopwatch.StartNew();
await DoWorkAsync(); // You hit a breakpoint here, examine variables for 30 seconds...
if (stopwatch.Elapsed > TimeSpan.FromSeconds(5)) {
throw new TimeoutException(); // False timeout!
}
The Solution¶
The Solution
// Debugger-aware timeout - ignores breakpoint time
using var clock = new DebuggerAwareClock();
var stopwatch = clock.StartNew();
await DoWorkAsync(); // You hit a breakpoint here, examine variables for 30 seconds...
if (stopwatch.HasTimedOut(TimeSpan.FromSeconds(5))) {
// Only triggers based on actual execution time
}
Core Types¶
IDebuggerAwareClock¶
The main clock service interface that creates stopwatches and tracks pause state.
IDebuggerAwareClock
public interface IDebuggerAwareClock : IDisposable {
// Current detection mode
DebuggerDetectionMode Mode { get; }
// True when execution is paused (breakpoint, external pause)
bool IsPaused { get; }
// Create a new stopwatch tracking active time
IActiveStopwatch StartNew();
// Subscribe to pause state changes
IDisposable OnPauseStateChanged(Action<bool> handler);
// Get current timestamp adjusted for debugger pauses
long GetCurrentTimestamp();
}
IActiveStopwatch¶
A stopwatch that distinguishes between active execution time and frozen/paused time.
IActiveStopwatch
public interface IActiveStopwatch {
// Time spent actually executing (excludes frozen periods)
TimeSpan ActiveElapsed { get; }
// Total wall clock time since start
TimeSpan WallElapsed { get; }
// Time spent paused/frozen (WallElapsed - ActiveElapsed)
TimeSpan FrozenTime { get; }
// Check if active time exceeds timeout
bool HasTimedOut(TimeSpan timeout);
// Stop the stopwatch, freezing all values
void Halt();
}
DebuggerDetectionMode¶
Configurable detection modes that trade off between accuracy and performance.
DebuggerDetectionMode
public enum DebuggerDetectionMode {
// Always use wall clock time (fastest, no detection)
Disabled,
// Detect only when System.Diagnostics.Debugger.IsAttached
DebuggerAttached,
// Use CPU time sampling to detect frozen periods
CpuTimeSampling,
// Wait for VS Code extension signals
ExternalHook,
// Auto-select best method based on environment (default)
Auto
}
| Mode | Best For | Detection at This Commit | Performance |
|---|---|---|---|
Disabled |
Production | None (IsPaused always false) |
Fastest |
DebuggerAttached |
Reserved | No active detection yet (no sampling timer) | Fast |
CpuTimeSampling |
External pauses | CPU/wall ratio sampling | Some overhead |
ExternalHook |
Reserved for VS Code extension | No active detection yet | Fast |
Auto |
Default | CPU sampling, gated on Debugger.IsAttached |
Balanced |
DebuggerAwareClockOptions¶
Configuration for the clock service.
DebuggerAwareClockOptions
public class DebuggerAwareClockOptions {
// Detection mode (default: Auto)
public DebuggerDetectionMode Mode { get; set; } = DebuggerDetectionMode.Auto;
// CPU sampling interval for CpuTimeSampling mode (default: 100ms)
public TimeSpan SamplingInterval { get; set; } = TimeSpan.FromMilliseconds(100);
// Ratio threshold to consider execution frozen (default: 10.0)
// If wall time / CPU time > threshold, considered frozen
public double FrozenThreshold { get; set; } = 10.0;
}
DebuggerAwareClock¶
The default implementation of IDebuggerAwareClock.
DebuggerAwareClock
// Default options (Auto mode)
using var clock = new DebuggerAwareClock();
// Custom options
using var clock = new DebuggerAwareClock(new DebuggerAwareClockOptions {
Mode = DebuggerDetectionMode.CpuTimeSampling,
SamplingInterval = TimeSpan.FromMilliseconds(50),
FrozenThreshold = 5.0
});
Usage Patterns¶
Basic Timeout Check¶
Basic Timeout Check
public class WorkCoordinator {
private readonly IDebuggerAwareClock _clock;
public WorkCoordinator(IDebuggerAwareClock clock) {
_clock = clock;
}
public async Task<Result> ProcessWithTimeoutAsync(TimeSpan timeout) {
var stopwatch = _clock.StartNew();
while (!stopwatch.HasTimedOut(timeout)) {
var result = await TryProcessAsync();
if (result.IsComplete) {
return result;
}
await Task.Delay(100);
}
throw new TimeoutException($"Operation timed out after {stopwatch.ActiveElapsed}");
}
}
Monitoring Pause State¶
Monitoring Pause State
// Subscribe to pause/resume events (useful for VS Code extension)
using var subscription = clock.OnPauseStateChanged(isPaused => {
if (isPaused) {
Console.WriteLine("Execution paused - likely at breakpoint");
} else {
Console.WriteLine("Execution resumed");
}
});
Performance Metrics¶
Performance Metrics
var stopwatch = clock.StartNew();
await DoWorkAsync();
stopwatch.Halt();
Console.WriteLine($"Wall time: {stopwatch.WallElapsed}");
Console.WriteLine($"Active time: {stopwatch.ActiveElapsed}");
Console.WriteLine($"Frozen time: {stopwatch.FrozenTime}");
// Example output when debugging:
// Wall time: 00:00:35.123
// Active time: 00:00:05.123
// Frozen time: 00:00:30.000 (30 seconds at breakpoint)
Dependency Injection¶
Whizbang registers IDebuggerAwareClock as a singleton:
Dependency Injection
builder.Services.AddWhizbang();
// Inject where needed
public class MyService {
private readonly IDebuggerAwareClock _clock;
public MyService(IDebuggerAwareClock clock) {
_clock = clock;
}
}
Custom Configuration¶
AddWhizbang() uses TryAddSingleton, so a registration you add before it wins:
Custom Configuration
// Register a custom-configured clock BEFORE AddWhizbang()
builder.Services.AddSingleton<IDebuggerAwareClock>(
new DebuggerAwareClock(new DebuggerAwareClockOptions {
Mode = DebuggerDetectionMode.CpuTimeSampling,
SamplingInterval = TimeSpan.FromMilliseconds(50)
})
);
builder.Services.AddWhizbang(); // TryAddSingleton - keeps your registration
How Detection Works¶
Auto Mode (Default)¶
- The CPU sampling timer runs continuously (every
SamplingInterval) - Each sample compares wall time delta to CPU time delta
- Execution is marked paused only when
Debugger.IsAttachedand the wall/CPU ratio exceedsFrozenThreshold
CPU Time Sampling¶
The clock periodically samples Process.TotalProcessorTime and compares it to wall clock time:
- Wall time >> CPU time: Execution is frozen (breakpoint, sleep, etc.)
- Wall time ~ CPU time: Normal execution
In CpuTimeSampling mode this works even when the debugger is not attached, detecting external pauses.
Updated
At this commit, only CpuTimeSampling and Auto modes start the sampling timer. DebuggerAttached and ExternalHook are defined in the enum but perform no active pause detection yet — in those modes IsPaused remains false. There is no public SignalPause()/SignalResume() API on DebuggerAwareClock; ExternalHook is reserved for future VS Code extension integration. Use OnPauseStateChanged to observe pause transitions detected by CPU sampling.
Integration Points¶
The debugger-aware clock is wired into Whizbang where false timeouts hurt most during debugging:
| Component | Usage |
|---|---|
Perspective Sync (PerspectiveSyncAwaiter) |
Sync waits time out on active time, not wall time |
| Generated Dispatcher (send-and-wait paths) | Resolves IDebuggerAwareClock for timeout tracking |
Best Practices¶
- Use Auto mode in development - It adapts to your debugging style
- Use Disabled in production - Zero overhead when not debugging
- Inject via DI - Use the singleton
IDebuggerAwareClock - Always dispose - The clock uses timers that need cleanup
- Halt stopwatches - Call
Halt()when done to freeze values
Testing¶
For unit tests, you can control the clock behavior:
Testing
[Test]
public async Task WorkCoordinator_Timeout_UsesActiveTimeAsync() {
// Arrange
var options = new DebuggerAwareClockOptions {
Mode = DebuggerDetectionMode.Disabled // Predictable behavior
};
using var clock = new DebuggerAwareClock(options);
// Act & Assert
var stopwatch = clock.StartNew();
await Task.Delay(100);
await Assert.That(stopwatch.HasTimedOut(TimeSpan.FromSeconds(1))).IsFalse();
}
See Also¶
- Observability - OpenTelemetry integration
- Work Coordination - Batch processing
- Health Checks - System health monitoring