更新 2:wget 1.16.1 版本已包括补丁,问题彻底解决。
更新 1:我的补丁已经被接受了,Bug 已修复。
wget
是 GNU 开发的实用下载工具,最近它刚刚发布了 v1.16。
进度条样式
先介绍一下背景,wget
向来就有两种进度条,“点形”和“条形”,其中,“条形”还有可选的跑马灯效果。
“刷屏点形”是指这样的效果
0K .......... .......... .......... .......... .......... 0% 107K 8m30s
50K .......... .......... .......... .......... .......... 0% 52.0K 13m0s
100K .......... .......... .......... .......... .......... 0% 31.3K 18m21s
150K .......... .......... .......... .......... .......... 0% 22.0K 24m4s
如果你的终端设备功能有限,不能做到即时更新屏幕内容,或者是重定向到了文件,“点形”进度条就很实用。
“条形”就是指这样的效果
filename 0%[ ] 134.05K 59.7KB/s
很适合可以即时更新的终端。
这两种类型可以使用
wget --progress=dot
wget --progress=bar
切换。
跑马灯
现在问题来了。如果文件名称很长,条形进度条左侧那一点点空间显示不下该怎么办呢?wget
的开发者想出了一个跑马灯效果,很像大街上的 LED 横幅,不停的让文字滚动,这样用户就可以“管窥”整个文件名了。
然而,盖子发现一个特别郁闷,而且能逼死强迫症患者的问题。wget
的滚动有 Bug,文件名的最后一个字符始终不显示!就象这样:
this_is_a_
his_is_a_f
is_is_a_fi
s_is_a_fil
_is_a_file
is_a_file_
s_a_file_n
_a_file_na
a_file_nam
<-- WTF!
this_is_a_
实现
在 progress.c
中,不难发现这段代码
if (((orig_filename_cols > MAX_FILENAME_COLS) && !opt.noscroll) && !done)
offset_cols = ((int) bp->tick) % (orig_filename_cols - MAX_FILENAME_COLS);
else
offset_cols = 0;
offset_bytes = cols_to_bytes (bp->f_download, offset_cols, cols_ret);
bytes_in_filename = cols_to_bytes (bp->f_download + offset_bytes, MAX_FILENAME_COLS, cols_ret);
memcpy (p, bp->f_download + offset_bytes, bytes_in_filename);
p += bytes_in_filename;
由于有一些字符占用 1 字节,有些占用 2 字节,因此下面部分的代码全都在处理把字符转换成字节数的问题,其实这段代码做的事情很简单
if (orig_filename_cols > MAX_FILENAME_COLS
&& !opt.noscroll // 没有禁用跑马灯滚动效果
&& !done) // 下载仍未结束
offset_cols = bp->tick % (orig_filename_cols - MAX_FILENAME_COLS);
bp->tick
是 int
,每刷新一次进度条,它会就自增 1,可以把它理解成进度条刷新的次数,(orig_filename_cols - MAX_FILENAME_COLS)
就不用多说了,显然是计算文件名超出最大允许长度的字符数。
最后,从 offset_cols 开始截取 orig_filename_cols,一直截取 MAX_FILENAME_COLS 个字符。
用取余数运算来实现不断截取字符串的特性,看起来还是挺巧妙的。不过,正是这里的代码存在着问题。
范围差 1
写程序的时候,经常因为该 + 1
/- 1
而忘记了(len()
vs. 下标),不该加减 1 的时候乱加减,导致典型的越界往往和正确范围只差 1 位。
再比如这些令人困惑的表述
def range(begin, end)
/* 11 日到 21 日间断电 */
/* 变量取值范围 0 ~ 256 */
到底包不包括这个 end,或者包不包括 21 日,或者 256?P.S:幸好数学家们早就意识到了这个问题,发明了区间表示法,圆括号表示“排除”,方括号表示“包括”。
而 wget
的“最后一个字符始终不显示”的 Bug,具有典型的“范围差 1”的特征,那真正的问题到底出不出在这里呢?
实验
为了看看 wget
的这个算法到底有没有 Bug,写个程序检验一下。
FILENAME = "this_is_a_file_name"
MAX = 10
def cut(string, _min, _max):
assert _max <= len(string) - 1
return string[_min:_max]
for i in range(0, 20):
offset = i % (len(FILENAME) - MAX)
print(offset, offset + MAX, cut(FILENAME, offset, offset + MAX))
这就是 Python 版本的简单算法实现了,运行之后:
0 10 this_is_a_
1 11 his_is_a_f
2 12 is_is_a_fi
3 13 s_is_a_fil
4 14 _is_a_file
5 15 is_a_file_
6 16 s_a_file_n
7 17 _a_file_na
8 18 a_file_nam
<-- WTF!
0 10 this_is_a_
果然出错?那么问题究竟出在哪里呢?拿这个例子分析一下,”this_is_a_file_name” 有 19 个字符,而最大的允许字符是 10 个。那么,那么,相差 9,看来编写者认为 9 就是需要的滚动次数。然而,滚动 9 次,就是有 10 种组合啊!
果然忘记 + 1
,而接下来就不用多说了。就等开发者接受补丁了。真是低级错误不可避啊……
2020年07月23日 — 14:43
「然而,滚动 9 次,就是有 10 种组合啊!」,这句想到10选9组合数,即C(10, 9)=10,然而细思不能跑到这个思路上来,而是相差为N,滚动次数为N+1。
在Excel中,若A1为文件名,MAX为跑马灯最大长度的自定义名称,则跑马灯公式可为=MID($A$1, 1+MOD(ROW(A1)-1, LEN($A$1)-MAX+1), MAX) (其中A1长度不小于MAX)。Excel下拉操作一番,增强了个人对本文的理解。
2015年01月05日 — 11:32
It’s the funniest bug I’ve ever seen…
BTW will you take part in Fedora 21 Release Party this winter vacation?
2014年11月15日 — 22:50
話說控制台動畫可以用 https://asciinema.org/docs/embedding 嵌入, 更形象
2014年11月16日 — 16:54
由于动画非文字不可索引,除非必需,一般还是不倾向于使用。不过感谢推荐,原来 Asciinema 已经支持嵌入了。