From e00086c01cf7bfd13f5e44640648143139f91a88 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 21 Sep 2026 16:15:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Pavo=20=E5=90=8E=E7=AB=AF=20isolate=20?= =?UTF-8?q?=E9=97=AD=E5=8C=85=E6=8D=95=E8=8E=B7=E5=AE=9E=E4=BE=8B=E9=93=BE?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=20Windows=20=E7=8A=B6=E6=80=81=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=BF=85=E7=82=B8=EF=BC=88unsendable=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 Windows 实测:未装驱动点跳过后页面报"状态读取失败 Illegal argument in isolate message: object is unsendable - _Timer"。堆栈定位: Isolate.run(_statusSync) 的方法 tear-off 会连带 this(PavoPrintBackend)→ _log(agent.log.write tear-off)→ AgentLog → StreamController → UI 日志订阅链 → 向导轮询 Timer——Timer 不可跨 isolate 发送,消息序列化直接抛错。mac 上 CUPS 后端不走 Isolate.run,从未暴露。 修复:isolate 入口全部改为顶层函数(pavoStatusSync / pavoPrintSync),不捕获 任何实例状态;printOneCard 先解出纯字符串(data URL/份数)再进闭包,最小化 捕获面。新增守护测试 pavo_isolate_test:在活跃日志订阅下调 getStatus, 回归捕获 this 时会立即复现 unsendable。14 测试全过、analyze 零问题。 --- lib/print/pavo/pavo_backend.dart | 178 +++++++++++++++++-------------- test/pavo_isolate_test.dart | 29 +++++ 2 files changed, 126 insertions(+), 81 deletions(-) create mode 100644 test/pavo_isolate_test.dart diff --git a/lib/print/pavo/pavo_backend.dart b/lib/print/pavo/pavo_backend.dart index 69d04f2..deb8285 100644 --- a/lib/print/pavo/pavo_backend.dart +++ b/lib/print/pavo/pavo_backend.dart @@ -15,7 +15,11 @@ import 'pavo_printer.dart'; /// 所有 ffi 调用(含像素转换的 CPU 密集计算)都放进 `Isolate.run`: /// PavoApi 的同步调用最长可阻塞数秒(发送 2MB 像素)、打印轮询最长 3 分钟, /// 放独立 isolate 保证主 isolate 的 HTTP 服务(任务状态轮询)不被卡住。 -/// DLL 与非托管指针都不跨 isolate(闭包内自建自释放)。 +/// +/// **重要**:传给 Isolate.run 的闭包/函数不得捕获任何实例状态(this 链会经 +/// onLog→AgentLog→StreamController→UI 订阅链一路拽到 Timer 等 unsendable 对象, +/// isolate 消息序列化直接抛 "Illegal argument in isolate message")—— +/// 因此 isolate 入口全部是顶层函数,只以纯数据(字符串)进出。 class PavoPrintBackend implements PrintBackend { PavoPrintBackend({void Function(String line)? onLog}) : _log = onLog ?? noopLog; @@ -25,97 +29,109 @@ class PavoPrintBackend implements PrintBackend { String get name => 'pavo'; @override - Future getStatus() => Isolate.run(_statusSync); - - PrinterStatus _statusSync() { - try { - final api = PavoApi(); - final printers = PavoPrinter.enumUsbPrinters(api); - if (printers.isEmpty) { - return PrinterStatus.offline('打印机未连接'); - } - final printer = printers.first.toNativeUtf16(); - try { - final status = PavoPrinter.getStatus(api, printer); - final (ribbonType, remainCount) = PavoPrinter.getRibbonInfo(api, printer); - final dto = PrinterStatus( - online: true, - model: PavoPrinter.getDeviceInfoText(api, printer, PavoApi.devinfoModelName), - firmware: PavoPrinter.getDeviceInfoText(api, printer, PavoApi.devinfoFirmwareVersion), - connection: 'USB', - ribbon: RibbonInfo( - type: PavoPrinter.ribbonTypeName(ribbonType), - // 设备返回剩余张数,按常见 100 张装 YMCKO 色带折算百分比 - remain: ribbonType == PavoApi.ribbonMissing ? -1 : (remainCount > 100 ? 100 : remainCount), - remainCount: remainCount, - ), - status: status, - statusText: PavoPrinter.statusTextOf(status), - ); - if (status == PavoApi.dsCardLow || status == PavoApi.dsCardOut) { - dto.cardLow = true; - } - return dto; - } finally { - calloc.free(printer); - } - } catch (e) { - return PrinterStatus.offline('状态读取失败:$e'); - } - } + Future getStatus() => Isolate.run(pavoStatusSync); @override Future printOneCard(PrintRequest request) { _log('Pavo 直打任务开始(份数 ${request.copies},后端在子 isolate 执行)'); final front = request.front!; final back = request.back; - // 只把 data URL 字符串与份数传进 isolate;图层转换与 ffi 调用全部在子 isolate 内完成 + // 先解出纯数据再进闭包:闭包只捕获字符串/整数(最小捕获面,杜绝实例链) final frontColor = front.color; final frontK = front.k; final backColor = back?.color; final backK = back?.k; final copies = request.copies; - return Isolate.run(() async { - final api = PavoApi(); - final printers = PavoPrinter.enumUsbPrinters(api); - if (printers.isEmpty) { - throw StateError('未找到 USB 连接的 HiTi 证卡打印机'); - } - final printer = printers.first.toNativeUtf16(); - try { - final frontImg = CardImage.fromDataUrl(frontColor); - final frontBgr = frontImg.toBgr24(); - final Uint8List? frontKGray; - if (frontK != null && frontK.isNotEmpty) { - frontKGray = CardImage.fromDataUrl(frontK).toGray8(); - } else { - frontKGray = null; - } - final Uint8List? backBgr; - final Uint8List? backKGray; - if (backColor != null && backColor.isNotEmpty) { - final backImg = CardImage.fromDataUrl(backColor); - backBgr = backImg.toBgr24(); - backKGray = - backK != null && backK.isNotEmpty ? CardImage.fromDataUrl(backK).toGray8() : null; - } else { - backBgr = null; - backKGray = null; - } - - await PavoPrinter.printOneCard( - api, - printer, - frontBgr: frontBgr, - frontK: frontKGray, - backBgr: backBgr, - backK: backKGray, - isLandscape: frontImg.isLandscape, + return Isolate.run(() => pavoPrintSync( + frontColor: frontColor, + frontK: frontK, + backColor: backColor, + backK: backK, copies: copies, - ); - } finally { - calloc.free(printer); - } - }); + )); + } +} + +/// isolate 入口:查询打印机状态(顶层函数,不捕获任何实例状态)。 +PrinterStatus pavoStatusSync() { + try { + final api = PavoApi(); + final printers = PavoPrinter.enumUsbPrinters(api); + if (printers.isEmpty) { + return PrinterStatus.offline('打印机未连接'); + } + final printer = printers.first.toNativeUtf16(); + try { + final status = PavoPrinter.getStatus(api, printer); + final (ribbonType, remainCount) = PavoPrinter.getRibbonInfo(api, printer); + final dto = PrinterStatus( + online: true, + model: PavoPrinter.getDeviceInfoText(api, printer, PavoApi.devinfoModelName), + firmware: PavoPrinter.getDeviceInfoText(api, printer, PavoApi.devinfoFirmwareVersion), + connection: 'USB', + ribbon: RibbonInfo( + type: PavoPrinter.ribbonTypeName(ribbonType), + // 设备返回剩余张数,按常见 100 张装 YMCKO 色带折算百分比 + remain: ribbonType == PavoApi.ribbonMissing ? -1 : (remainCount > 100 ? 100 : remainCount), + remainCount: remainCount, + ), + status: status, + statusText: PavoPrinter.statusTextOf(status), + ); + if (status == PavoApi.dsCardLow || status == PavoApi.dsCardOut) { + dto.cardLow = true; + } + return dto; + } finally { + calloc.free(printer); + } + } catch (e) { + return PrinterStatus.offline('状态读取失败:$e'); + } +} + +/// isolate 入口:转换图层并直打(顶层函数,参数全部为可发送的纯数据)。 +Future pavoPrintSync({ + required String frontColor, + String? frontK, + String? backColor, + String? backK, + required int copies, +}) async { + final api = PavoApi(); + final printers = PavoPrinter.enumUsbPrinters(api); + if (printers.isEmpty) { + throw StateError('未找到 USB 连接的 HiTi 证卡打印机'); + } + final printer = printers.first.toNativeUtf16(); + try { + final frontImg = CardImage.fromDataUrl(frontColor); + final frontBgr = frontImg.toBgr24(); + final Uint8List? frontKGray = + frontK != null && frontK.isNotEmpty ? CardImage.fromDataUrl(frontK).toGray8() : null; + final Uint8List? backBgr; + final Uint8List? backKGray; + if (backColor != null && backColor.isNotEmpty) { + final backImg = CardImage.fromDataUrl(backColor); + backBgr = backImg.toBgr24(); + backKGray = + backK != null && backK.isNotEmpty ? CardImage.fromDataUrl(backK).toGray8() : null; + } else { + backBgr = null; + backKGray = null; + } + + await PavoPrinter.printOneCard( + api, + printer, + frontBgr: frontBgr, + frontK: frontKGray, + backBgr: backBgr, + backK: backKGray, + isLandscape: frontImg.isLandscape, + copies: copies, + ); + } finally { + calloc.free(printer); } } diff --git a/test/pavo_isolate_test.dart b/test/pavo_isolate_test.dart new file mode 100644 index 0000000..ad883f6 --- /dev/null +++ b/test/pavo_isolate_test.dart @@ -0,0 +1,29 @@ +import 'package:desktop_plugin/agent.dart'; +import 'package:desktop_plugin/print/pavo/pavo_backend.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 守护:Pavo 后端的 isolate 闭包不得捕获实例状态。 +/// +/// 曾经的 bug:Isolate.run(_statusSync) 的方法 tear-off 经 this._log → +/// AgentLog → StreamController → UI 订阅链一路拽到 Timer(unsendable), +/// isolate 消息序列化直接抛 "Illegal argument in isolate message"。 +/// Windows 真实 App(UI 监听日志流)必炸;本测试在有活跃日志订阅的环境下调 +/// getStatus,若回归捕获 this 会立刻复现 unsendable。 +void main() { + test('PavoPrintBackend.getStatus 在活跃日志订阅下 isolate 闭包可发送', () async { + final agent = Agent(AgentOptions(mock: true, port: 38296)); + await agent.start(); + final sub = agent.log.stream.listen((_) {}); + addTearDown(() async { + await sub.cancel(); + await agent.stop(); + }); + + final backend = PavoPrintBackend(onLog: agent.log.write); + // mac 上无 PavoApiW_x64.dll:期望走 offline 分支(DLL 加载失败被内部 + // catch 转友好文案),而绝不是抛 "unsendable" isolate 消息错误 + final status = await backend.getStatus(); + expect(status.online, isFalse); + expect(status.statusText, isNot(contains('unsendable'))); + }); +}