Flutter并发Shellby

下载不覆盖,不能只靠 exists()——Shellby 的 SFTP 队列修复

Shellby 开发日志:两个同名下载都通过「文件不存在」检查,仍可能写进同一路径。把选名改成排他创建后,还得一起处理本地写入进度、取消收尾与续传的代次,才能让队列状态对得上文件状态。

Shellby 的 Flutter SFTP 下载队列支持两个任务并发,遇到重名文件会追加 (1)、 (2)。界面上看起来已经有「不覆盖」保护,代码里也确实先查了文件是否存在。

但检查存在性和拿到文件的写入权,是两回事。

11 月 3 日的提交 64babba 把这条链路重做了一遍。这篇记录的是该提交里的开发实现与回归用例,不代表对应改动已经在商店版本中发布,也不把源码检查当成真机验收。

两个不存在,可以指向同一个文件

旧流程在页面里选目标名,再把路径交给传输队列。逻辑大致是:

if (!await file.exists()) return file;
// 否则尝试 file (1)、file (2)……

真正写入发生在后面的任务启动阶段,首传用 openWrite(mode: FileMode.write)。这中间有一个空窗:

任务 A:report.txt 不存在 → 选择 report.txt
任务 B:report.txt 不存在 → 选择 report.txt
任务 A:打开 report.txt,开始写入
任务 B:打开同一路径,开始写入

这不是只在多个线程之间才有的竞态。一个异步队列里,两次 await 之间也足够让其他任务推进。更何况选名时任务还没真正占用文件,两个请求看到「不存在」都没有撒谎。

问题在于代码把一个瞬时观察,当成了后续操作的凭证。

选名时,直接把文件占下来

修复后,页面只提供期望路径。传输管理器真正启动下载时,用排他创建取得文件:

await File(candidate).create(exclusive: true);
t._localPath = candidate;
t._ownsLocalFile = true;

同名的两个任务可以同时尝试 report.txt,但不能都成功创建它。没有拿到这个名字的任务,继续尝试 report (1).txt。后缀放在扩展名前,后续续传使用任务实际占下来的路径。

这个变化让职责也更明确了:决定写到哪里的代码,同时负责取得该目标。 页面不再返回一张尚未兑现的「空路径收据」。

失败分支也有一个容易漏掉的细节。创建失败不能一律当成重名,否则父目录不存在或本地 I/O 出错时,程序会不停换名字,永远不向用户报错。当前实现捕获 FileSystemException 后检查该路径的实体类型;目标仍不存在时把错误抛出,只有发现已有实体才继续避让。

_ownsLocalFile 还用在清理上。失败或取消任务被移出队列时,只删除任务自己创建的残件;没有取得目标所有权的任务,没有资格清理那个路径。

这里的保证有范围:它堵住的是同名任务竞争创建目标,以及初始目标已存在时被直接覆盖的问题。创建与随后打开文件仍是两个操作,不能据此声称能防住任意本地进程在这期间替换路径。

收到字节,不等于已经写完字节

原来下载流的回调拿到一块数据,就调用 IOSink.add(),然后递增 done。网络流结束后再关闭 sink,但关闭错误被清理函数吞掉了。

因此「已下载字节数」混进了另一个含义:交给写入缓冲区的字节数。它还不一定是本地写操作已经完成的字节数。用这个值给续传做 offset,本地文件和远端读取位置可能对不上;忽略关闭失败,又会让任务有机会显示完成,却没有把本地错误交代清楚。

新实现改成逐块等待本地写入,再推进计数:

while (await iterator.moveNext()) {
  if (_disposed || t._generation != generation) break;
  final chunk = iterator.current;
  await file.writeFrom(chunk);
  t.done += chunk.length;
  _progress(t);
}

StreamIterator 让取下一块与写当前块排成同一条异步控制流。收尾放在 finally,close() 失败也会记入失败结果。只有循环与关闭都没有报错,而且本次运行仍有效,才把状态落为完成。

续传打开本地文件后还会核对 length == done。如果残件在中途被截断或增长,就停止续传,避免带着已失效的 offset 继续拼接。

这也有意保留了一个边界:长度检查不能识别同长度的内容替换,更不能证明远端文件从未变化。这次没有实现内容哈希、远端版本绑定或跨重启恢复;等待写操作和关闭完成,也不是断电持久化保证。

取消后立刻续传,是另一次竞争

把文件 I/O 改为可等待的流程后,还得处理「用户点取消,马上又点续传」。状态可以先变成取消,但旧运行可能仍在关闭迭代器或文件。若新运行立即打开同一个残件,旧收尾与新写入就会重叠。

修复用了两层约束:

  1. 每次启动增加 _generation,取消也增加它;异步返回后检查自己捕获的代次是否仍然有效。
  2. 任务保存本次下载的 _downloadRun;新运行先等待前一次结束,再取得客户端和开始写入。

二者解决不同的问题。等待旧运行,保证两个运行不会同时操作本地文件;检查代次,保证已经过时的异步结果不能回来修改新运行。

同一个检查也放在续传换客户端的 await provider() 后。即使新客户端迟到了,只要等待期间发生过取消,它就不能再被安装成当前客户端,更不能启动已经撤销的传输。

回归用例要控制顺序

这次新增的测试没有靠「并发多跑几次,看看能不能撞上」。它们用可控的假 SFTP 流和真实临时文件,明确安排事件顺序:

  • 原文件已有内容,下载完成后原内容不变,任务改用带编号的新路径。
  • 两个任务请求同一路径,最终路径不同,两份流的内容都被保留。
  • 发生重名、写入一部分后连接报错,续传沿用已预留的新名字和 offset。
  • 取消还在收尾时立即重试,最终文件由前后两段正确拼接。
  • 换客户端尚未返回时取消,迟到的客户端不能改写管理器状态。
  • 目标父目录不存在时,任务明确失败,不陷进换名循环。

这些用例存在于 sftp_transfer_manager_test.dart。本文核对了提交差异、实现和测试断言,没有在写作时重跑 Flutter 测试,也没有新增设备实测结论。

这轮修复里最有用的变化,是把「任务拥有的东西」说清楚:文件名要靠创建取得,进度要跟着成功的写操作走,旧运行结束以后,新运行才接手同一个文件。界面上的一个完成状态,背后其实是这几件事按顺序兑现。

✦

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。