У меня есть настольное приложение C# WPF, которое печатает счета на термальный принтер с использованием Пользовательской очереди печати (печать в стиле ESC/POS).
Проблема:
Если приложение бездействует или свернуто ~10 минут, печать перестает работать:
- Задание печати создано успешно.
- Оно появляется в очереди печати
- Задание никогда не печатается (не приостановлено, нет ошибок) — Показать ожидание.
- Исключение не создается. приложение
- Если я перезапущу приложение, печать заработает немедленно
Что работает:
- Перезапуск приложения (не требуется перезагрузка системы или принтера)
- Возврат приложения на передний план
- Повторная попытка печати
- Принтер подключен к сети и реагирует
- Рабочий стол Windows
- WPF (.NET)
- Термопринтер
- Приложение продолжает работать (не приостановлено и не закрыто)
- Что могло привести к зависанию заданий печати после того, как приложение было бездействующим/свернутым?
- Временно Я исправил это, запустив в фоновом режиме. Плохая ли практика поддерживать работу приложения с помощью таймера/пульса?
- Рекомендуется ли подход к:
- Повторной инициализации объектов принтера перед каждой печатью?
- Повторному созданию службы печати после бездействия?
- Избегайте повторного использования полностью связанные с принтером объекты?
Код: Выделить всё
public class PrintQueueProcessor : IDisposable
{
private readonly IDbContextFactory _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 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(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
}
Подробнее здесь: https://stackoverflow.com/questions/798 ... -minimized