「删个文件而已,至于卡死吗。」

这是测试那边甩给我的一句话,配了张图:FTP 窗口里一行「已删除」,然后整个窗口就没反应了——点什么都不动,刷新不动,连新加的「重连」按钮也一直停在「正在连接…」。

我第一反应是不太信。删文件这事,一百兆的文件也是秒删,能把一个窗口整死?

自己试了一遍,真死。而且是必现,删了就卡。

先按老办法怀疑了一圈:服务器那端是不是有问题?换台机器连,照卡。网络?ping 一直是稳的。FluentFTP 的 bug?翻了半天 issue,对不上号。最后只好坐下来把那几行刷新逻辑从头看一遍,才看出问题不在外面。

FTP 的控制连接就一条。为了不让几个操作挤在同一条连接上互相打架,我给它上了一把信号量,叫 _opLock,谁要发 FTP 命令谁先拿锁。删除、新建文件夹、重命名、移动,这四处操作本身就是持着锁进去的。偏偏我在里面又调了一次「刷新目录」,而刷新的那个方法 LoadRemoteAsync() 一进门,第一件事还是拿这把锁。

SemaphoreSlim 不可重入。同一个线程手里攥着锁,再去 await 它自己,就永远等下去。锁放不开,后面的刷新、传输、重连全堵死。那句「已删除」是最前面打出来的,也就是说删除早成功了,卡的是它后面那一步刷新——一百兆的删除是瞬时的,卡死跟文件大小一点关系没有。

改法不复杂:把刷新的实体抽成一个不加锁的 LoadRemoteCoreAsync(),持锁的人调它;原来那个会自己加锁的 LoadRemoteAsync() 留给外面用。四处持锁的操作统一改过去。

顺手对了一下 SFTP。它压根没这把锁,同样的删除、重命名,一点事没有。那会儿我还以为这就完了,一个孤例。

事情没那么简单

接着测试那边又甩来一段:传一个「里面好多小文件」的文件夹,传到一半再点另一个文件夹上传,整个软件直接卡死,只能开任务管理器杀。还有,传大文件传到一半按取消,也死。

我打开 FTP 传文件夹的那段代码一看,明白了:它把文件夹里每一个文件都平铺成一条队列项。一个几百上千个小文件的文件夹,队列就是几千行,界面线程反复重建、刷新这张表,不卡才怪。传输进度列还是自绘的,可刷新的时候根本没重画,所以进度条永远停在 0,看着像没在传。查远端有没有同名文件那段,又是逐文件往返问服务器,还没走 _opLock,正好跟正在跑的传输抢同一条控制连接——雪上加霜。

对照 SFTP,问题一下就清楚了:SFTP 传文件夹时,队列里从头到尾只有「文件夹」一行,百分比按总字节正常走。那就把 FTP 对齐到 SFTP 的行为——文件夹上传/下载算一个任务,内部自己递归,进度按总字节算,队列里就一行。查远端同名,改成先把目标目录整个列一遍再比,不再一个文件一个文件问,全程走 _opLock

差点删了用户的源文件

这段代码翻着翻着,翻出一个让我后背发凉的 bug。

取消上传的那段处理里,旧代码有一句 File.Delete(t.Local)。问题是,上传任务的 t.Local 指的是用户本地的源文件。也就是说,用户传一个大文件传到一半按了取消,程序会把人家本地那个源文件给删了。

我盯着那句看了好一会儿。这功能上线有一阵了,居然没人触发到——大概是取消上传的人不多,或者删了也没往这上面想。现在改成只有「下载单个文件」取消或失败时才清本地那半截,上传这条路径,一律不碰本地文件。

取消为什么打不断

FTP 那个取消卡死,是我花时间最多的一个。SFTP 上取消很干脆,FTP 上却经常按了没反应。

根因在传输库:FluentFTP 有时候打不断一个正在跑的传输,尤其是卡在数据连接半道上的时候。任务不结束,锁一直被占着,刷新、传输、重连就又全堵了——跟前面那个死锁一个后果,只是成因不同。SFTP 为什么没事?它用的是自己写的流,取消的时候直接从 I/O 层抛异常,传输当场断掉。

那就照抄这个思路。给 FTP 也套一个自己的流 FtpIoStream,包住本地文件流,上传走 UploadStream、下载走 DownloadStream,取消时从流里抛异常打断。再加一道看门狗:点了取消,五秒还不停,就强制断开那条连接,保证锁一定放得出来,不再整个窗口干等。

还有些零碎的

FTP 的「跳过相同文件(MD5)」,一直被误会成宝塔不支持。其实不是——FTP 协议里就没有「让服务器算一下远端文件 MD5」这种命令,只能把远端文件下载回来、本地算完再比,费流量是必然的。这个说明我加进了「比对方式选择」那个提示框,用橙色小字提示一下,功能不关。顺带修了两个显示 bug:速度被前面的比对耗时摊成了 0,还有跳过文件时进度条自己往前跑。

免责声明弹窗那个,我改了三个版本。先是按版本号记,换版本弹一次;想想觉得烦,改成点一次永久不弹;最后老大又说还是要「更新之后弹一次」——绕了一圈,收回到最初那版。这种来回,做过产品的人都懂。

服务器分组也改了小十版。标题行放哪一列、要不要箭头、点名字折叠还是加箭头、组名染不染色,来回调,最后一版是:每组一行标题,组名按组染色,点名字折叠展开。

还有个特别小的坑:文件列表的列头排序,修好「能点」之后发现「只能点一次」,再点想切升降序就不动了。原因是 WinForms 的 ListView,反复设同一个 Sorting 值不会触发重排。改成每次点击都换一个新的 sorter 再显式 Sort() 一次,就正常了。

就这样

一天下来,从一个「删个文件」开始,扯出一个死锁、一个差点删掉用户源文件的 bug,还有整套传输取消机制的重写。更新包前后发了十四个,大半时间花在等复现、等验证上。

这一版走 1.6.5,官网的更新地址我没动,客户端暂时收不到更新提示——先把软件发出去,等这堆东西都稳了再统一放。

写这篇的时候我又看了一眼那句 File.Delete,还是有点后怕。就一个删文件的坑,拖出这么多东西,也是没想到。