WPF C# thermal printer job stuck in print queue after app is idle/minimized, works only after app restart

3 weeks ago 15
ARTICLE AD BOX

Question

I have a C# WPF desktop application that prints invoices to a thermal printer using the Custom Print Queue (ESC/POS–style printing).

Issue:
If the application is idle or minimized for ~10 minutes, printing stops working:

The print job is created successfully

It appears in the print queue

The job never prints (not paused, no error) - Show Pending.

No exception is thrown in the application

If I restart the application, printing works immediately

The printer itself is fine and prints normally from other applications.

What works:

Restarting the app (no system or printer restart needed)

What does NOT help:

Bringing the app back to foreground

Retrying the print

Printer is online and responsive

Environment:

Windows desktop

WPF (.NET)

Thermal printer

App remains running (not suspended or closed)

Questions:

What could cause print jobs to get stuck after the app has been idle/minimized?

Temporary I fixed it by running in background. Is it bad practice to keep the app “alive” using a timer/heartbeat?

Is the recommended approach to:

Reinitialize printer objects before each print?

Recreate the print service after inactivity?

Avoid reusing printer-related objects entirely?

I’m looking for best practices for handling printing reliability in long-running WPF apps, especially with thermal printers.

public class PrintQueueProcessor : IDisposable { private readonly IDbContextFactory<AppDbContext> _contextFactory; private readonly ThermalPrinterService _thermalPrinterService; private Timer? _processingTimer; private Timer? _cleanupTimer; private Timer? _keepAliveTimer; private readonly object _lock = new(); private bool _isProcessing; private bool _isRunning; private CancellationTokenSource? _cts; private Task? _currentProcessingTask; public PrintQueueProcessor( IDbContextFactory<AppDbContext> contextFactory, ThermalPrinterService thermalPrinterService) { _contextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); _thermalPrinterService = thermalPrinterService ?? throw new ArgumentNullException(nameof(thermalPrinterService)); Log.Information("PrintQueueProcessor initialized"); } public void Start() { lock (_lock) { if (_isRunning) { Log.Warning("Print queue processor already running"); return; } _isRunning = true; _cts = new CancellationTokenSource(); _processingTimer = new Timer( ProcessPendingJobsCallback, null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(3)); _cleanupTimer = new Timer( CleanupCallback, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5)); _keepAliveTimer = new Timer( KeepAliveCallback, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2)); Log.Information("✅ Print Queue Processor STARTED (with keep-alive)"); } } #region Windows Print Spooler API [DllImport("winspool.drv", CharSet = CharSet.Auto, SetLastError = true)] private static extern bool OpenPrinter(string pPrinterName, out IntPtr phPrinter, IntPtr pDefault); [DllImport("winspool.drv", CharSet = CharSet.Auto, SetLastError = true)] private static extern bool ClosePrinter(IntPtr hPrinter); [DllImport("winspool.drv", CharSet = CharSet.Auto, SetLastError = true)] private static extern bool GetPrinter(IntPtr hPrinter, int Level, IntPtr pPrinter, int cbBuf, out int pcbNeeded); [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] private struct PRINTER_INFO_2 { public string pServerName; public string pPrinterName; public string pShareName; public string pPortName; public string pDriverName; public string pComment; public string pLocation; public IntPtr pDevMode; public string pSepFile; public string pPrintProcessor; public string pDatatype; public string pParameters; public IntPtr pSecurityDescriptor; public uint Attributes; public uint Priority; public uint DefaultPriority; public uint StartTime; public uint UntilTime; public uint Status; public uint cJobs; public uint AveragePPM; } private bool PingPrinter(string printerName) { IntPtr hPrinter = IntPtr.Zero; try { if (!OpenPrinter(printerName, out hPrinter, IntPtr.Zero)) { Log.Warning("⚠️ Cannot open printer: {Printer}", printerName); return false; } // Get printer info - this keeps connection alive GetPrinter(hPrinter, 2, IntPtr.Zero, 0, out int needed); if (needed > 0) { IntPtr pPrinterInfo = Marshal.AllocHGlobal(needed); try { if (GetPrinter(hPrinter, 2, pPrinterInfo, needed, out _)) { var info = Marshal.PtrToStructure<PRINTER_INFO_2>(pPrinterInfo); Log.Debug("🖨️ Printer '{Printer}' alive - Jobs: {Jobs}, Status: {Status}", printerName, info.cJobs, info.Status); return true; } } finally { Marshal.FreeHGlobal(pPrinterInfo); } } return true; } catch (Exception ex) { Log.Warning("⚠️ Printer ping failed: {Printer} - {Message}", printerName, ex.Message); return false; } finally { if (hPrinter != IntPtr.Zero) ClosePrinter(hPrinter); } } #endregion #region Timer Callbacks private void ProcessPendingJobsCallback(object? state) { if (_isProcessing || !_isRunning || (_cts?.IsCancellationRequested ?? true)) return; lock (_lock) { if (_isProcessing) return; _isProcessing = true; } _currentProcessingTask = Task.Run(async () => { try { await ProcessPendingJobsAsync(_cts!.Token); } catch (OperationCanceledException) { } catch (Exception ex) { Log.Error(ex, "Error in ProcessPendingJobsAsync"); } finally { lock (_lock) { _isProcessing = false; } } }); } private void CleanupCallback(object? state) { if (!_isRunning || (_cts?.IsCancellationRequested ?? true)) return; _ = Task.Run(async () => { try { await CleanupOldJobsAsync(_cts!.Token); } catch (OperationCanceledException) { } catch (Exception ex) { Log.Error(ex, "Error in CleanupOldJobsAsync"); } }); } private void KeepAliveCallback(object? state) { if (!_isRunning || (_cts?.IsCancellationRequested ?? true)) return; _ = Task.Run(async () => { try { await KeepPrintersAliveAsync(_cts!.Token); } catch (OperationCanceledException) { } catch (Exception ex) { Log.Debug("Keep-alive error: {Message}", ex.Message); } }); } #endregion #region Printer Keep-Alive private async Task KeepPrintersAliveAsync(CancellationToken cancellationToken) { try { await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); // Get unique printer names from recent print jobs var recentPrinters = await context.PrintQueueJobs .Where(j => j.CreatedAtUtc > DateTime.UtcNow.AddHours(-24)) .Select(j => j.PrinterName) .Distinct() .ToListAsync(cancellationToken); // Also get printers from template mappings var mappedPrinters = await context.PrinterTemplateMappings .Where(m => m.IsActive && !string.IsNullOrEmpty(m.PrinterName)) .Select(m => m.PrinterName) .Distinct() .ToListAsync(cancellationToken); var allPrinters = recentPrinters .Union(mappedPrinters) .Where(p => !string.IsNullOrWhiteSpace(p)) .Distinct() .ToList(); foreach (var printerName in allPrinters) { cancellationToken.ThrowIfCancellationRequested(); PingPrinter(printerName!); } } catch (OperationCanceledException) { throw; } catch (Exception ex) { Log.Debug("KeepPrintersAliveAsync: {Message}", ex.Message); } } #endregion #region Job Processing private async Task ProcessPendingJobsAsync(CancellationToken cancellationToken) { try { await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); var pendingJobs = await context.PrintQueueJobs .Where(j => j.Status == PrintJobStatus.Pending) .OrderByDescending(j => j.Priority) .ThenBy(j => j.CreatedAtUtc) .Take(5) .ToListAsync(cancellationToken); foreach (var job in pendingJobs) { cancellationToken.ThrowIfCancellationRequested(); await ProcessSingleJobAsync(context, job, cancellationToken); } } catch (OperationCanceledException) { throw; } catch (Exception ex) { Log.Error(ex, "Error in ProcessPendingJobsAsync"); } } private async Task ProcessSingleJobAsync(AppDbContext context, PrintQueueJob job, CancellationToken cancellationToken) { try { Log.Information("🖨️ Processing Job {JobId}: Bill={BillId}, Printer={Printer}", job.Id, job.BillId, job.PrinterName); job.Status = PrintJobStatus.Processing; job.LastAttemptAtUtc = DateTime.UtcNow; job.AttemptCount++; await context.SaveChangesAsync(cancellationToken); object? dataToPrint = null; if (job.BillId.HasValue) { dataToPrint = await context.Bills .Include(b => b.Items) .Include(b => b.Payments) .AsNoTracking() .FirstOrDefaultAsync(b => b.Id == job.BillId.Value, cancellationToken); if (dataToPrint == null) throw new InvalidOperationException($"Bill {job.BillId} not found"); } else if (!string.IsNullOrEmpty(job.Context)) { var kotId = ExtractKotIdFromContext(job.Context); if (kotId.HasValue) { dataToPrint = await context.Kots .Include(k => k.Items) .AsNoTracking() .FirstOrDefaultAsync(k => k.Id == kotId.Value, cancellationToken); if (dataToPrint == null) throw new InvalidOperationException($"KOT {kotId} not found"); } } if (dataToPrint == null) throw new InvalidOperationException("No data to print"); cancellationToken.ThrowIfCancellationRequested(); bool printSuccess = await _thermalPrinterService.PrintAsync( job.PrinterName, job.TemplateId, dataToPrint); if (printSuccess) { job.Status = PrintJobStatus.Completed; job.CompletedAtUtc = DateTime.UtcNow; job.ErrorMessage = null; Log.Information("✅ Job {JobId} COMPLETED!", job.Id); } else { throw new Exception("PrintAsync returned false"); } await context.SaveChangesAsync(cancellationToken); } catch (OperationCanceledException) { job.Status = PrintJobStatus.Pending; job.AttemptCount = Math.Max(0, job.AttemptCount - 1); await context.SaveChangesAsync(CancellationToken.None); throw; } catch (Exception ex) { Log.Error(ex, "❌ Job {JobId} failed: {Message}", job.Id, ex.Message); job.ErrorMessage = ex.Message; job.Status = job.AttemptCount >= job.MaxRetries ? PrintJobStatus.Failed : PrintJobStatus.Pending; await context.SaveChangesAsync(CancellationToken.None); } } private int? ExtractKotIdFromContext(string? context) { if (string.IsNullOrEmpty(context)) return null; var parts = context.Split(','); var kotPart = parts.FirstOrDefault(p => p.StartsWith("KOT:", StringComparison.OrdinalIgnoreCase)); if (kotPart != null) { var idParts = kotPart.Split(':'); if (idParts.Length > 1 && int.TryParse(idParts[1], out int kotId)) return kotId; } return null; } #endregion #region Cleanup public async Task CleanupOldJobsAsync(CancellationToken cancellationToken = default) { try { await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); var cutoffDate = DateTime.UtcNow.AddDays(-3); var oldJobs = await context.PrintQueueJobs .Where(j => j.CreatedAtUtc < cutoffDate) .Where(j => j.Status == PrintJobStatus.Completed || j.Status == PrintJobStatus.Failed) .ToListAsync(cancellationToken); if (oldJobs.Any()) { context.PrintQueueJobs.RemoveRange(oldJobs); await context.SaveChangesAsync(cancellationToken); Log.Information("🧹 Cleaned up {Count} old jobs", oldJobs.Count); } } catch (OperationCanceledException) { throw; } catch (Exception ex) { Log.Error(ex, "Error cleaning up old jobs"); } } #endregion #region Lifecycle public void Stop() { lock (_lock) { if (!_isRunning) return; Log.Information("Stopping PrintQueueProcessor..."); _isRunning = false; _cts?.Cancel(); _processingTimer?.Change(Timeout.Infinite, Timeout.Infinite); _cleanupTimer?.Change(Timeout.Infinite, Timeout.Infinite); _keepAliveTimer?.Change(Timeout.Infinite, Timeout.Infinite); } try { _currentProcessingTask?.Wait(TimeSpan.FromSeconds(3)); } catch (AggregateException) { } catch (TaskCanceledException) { } _processingTimer?.Dispose(); _cleanupTimer?.Dispose(); _keepAliveTimer?.Dispose(); _processingTimer = null; _cleanupTimer = null; _keepAliveTimer = null; Log.Information("PrintQueueProcessor stopped"); } public void Dispose() { Stop(); _cts?.Dispose(); _cts = null; } #endregion }

Tags

Read Entire Article