fix: Pavo 后端 isolate 闭包捕获实例链导致 Windows 状态查询必炸(unsendable)

用户 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 零问题。
This commit is contained in:
dzc committed 2026-09-21 16:15:27 +08:00
1 parent e88c9dcfa2
commit e00086c01c
2 files changed
+126 -81

No files matched your search

+97 -81
View File
@@ -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<PrinterStatus> 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<PrinterStatus> getStatus() => Isolate.run(pavoStatusSync);
@override
Future<void> 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<void> 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);
}
}
+29
View File
@@ -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')));
});
}