diff --git a/apps/mobile/lib/features/uploads/enqueue.dart b/apps/mobile/lib/features/uploads/enqueue.dart index 029b01c..62f36e3 100644 --- a/apps/mobile/lib/features/uploads/enqueue.dart +++ b/apps/mobile/lib/features/uploads/enqueue.dart @@ -149,16 +149,17 @@ Future enqueueForUpload( .where((path) => !skipSet.contains(path)) .toList(); - var enqueued = 0; - for (final path in effective) { - final task = await preparer.prepare( - accountId: accountId, - albumId: albumId, - categoryId: categoryId, - sourcePath: path, - ); - if (task.state == UploadTaskState.queued) enqueued++; - } + final results = await Future.wait( + effective.map((path) => preparer.prepare( + accountId: accountId, + albumId: albumId, + categoryId: categoryId, + sourcePath: path, + )), + ); + final enqueued = results + .where((task) => task.state == UploadTaskState.queued) + .length; return EnqueueResult( enqueued: enqueued, diff --git a/apps/mobile/test/features/uploads/preparer_test.dart b/apps/mobile/test/features/uploads/preparer_test.dart index 8d6fcc0..7a417a5 100644 --- a/apps/mobile/test/features/uploads/preparer_test.dart +++ b/apps/mobile/test/features/uploads/preparer_test.dart @@ -275,6 +275,46 @@ void main() { expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued)); expect(peakOf(), 1, reason: '并发 1 时任意时刻在途 prepare 至多 1(串行)'); }); + + test('maxConcurrentPrepares<1 钳位为 1(0 不死锁、串行等价)', () async { + final (p, peakOf) = probePreparer(0); + final srcs = [ + for (var i = 0; i < 3; i++) + sourceFile('clamp-$i.jpg', _jpgBytes + [i]), + ]; + final tasks = await Future.wait([ + for (final src in srcs) + p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path), + ]); + expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued)); + expect(peakOf(), 1, reason: '运行时 <1 按 1 处理:峰值 1(不死锁、不并行)'); + }); + + test('失败与抛错操作都释放并发名额(cap=1 后续 prepare 不被卡死)', () async { + final p = preparer(maxConcurrentPrepares: 1); + final src = sourceFile('after-error.jpg', _jpgBytes); + + // 业务失败:源不存在 → preparationFailed(正常返回、不抛)。 + final failed = await p.prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: '${sourceDir.path}/missing.jpg', + ); + expect(failed.state, UploadTaskState.preparationFailed); + + // 真错误:retry 不存在的任务 → StateError 穿过 _runBounded—— + // 钉死 whenComplete 语义(若改成 then,此处名额泄漏)。 + await expectLater( + p.retry(accountId: 'alice', taskId: 'no-such-task', sourcePath: src.path), + throwsA(isA()), + ); + + // 名额已释放:后续 prepare 正常完成(泄漏则卡死,超时快速失败)。 + final task = await p + .prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path) + .timeout(const Duration(seconds: 5)); + expect(task.state, UploadTaskState.queued); + }); }); group('串行回归(maxConcurrentPrepares=1 等价旧全局串行队列)', () {