前两天看到 @Nova Kwok 和 @BennyThink 做了一个 WebP Server,于是 clone 下来玩了一下,发现貌似没有做“原始图像更新后,重新生成相应的 WebP 图像”的功能。好的,这个说起来简单,做起来也很简单,就是 os.Stat
一下,然后取到图像最后修改时间的 UNIX timstamp,STAT.ModTime().Unix()
,最后再跟先前生成好的 WebP 文件名比较一下就好(timestamp 会放在生成的 WebP 文件名里)。
上面为止真的都很简单,在 macOS 上测试了一下,看起来没问题~于是就提交 Pull Request
然而 Nova 告诉我说,
Nice PR, but there seems a little problem that the older converted images are not deleted after the change of the original image, this might cause a possible leakage of the original one's content.
显然我是一头雾水,一开始还以为自己提交 PR 的时候是不是手滑删掉了几行,检查了一下之后发现并没有!然后姑且先把 macOS 上测试的截图 comment 在了 PR 下面。
接着我估计 Nova 应该是在 Linux 下跑的测试,于是就在一台新的 VPS 上安装了 go,把我 fork 且修改过的那份代码 clone 在 VPS 里测试。本来我预估的时候要么是我搞错了文件,要么也许是 Nova 不小心用了以前编译好的文件。然后一测试我就惊了,居然真的没有删除以前生成的 WebP 图像 Σ(・□・;)?!
由于没有 Linux 机器,也懒得安装虚拟机了,只能一头雾水的在 VPS 用 fmt.Println
输出来简易 debug 了。根据 fmt.Println
的输出,发现 ImgName
不知道为什么就突然之间被改了!
[1]ImgName: webp_server.png
[2]ImgName: webp_server.png
[3]ImgName: webp_server.png
[4]ImgName: webp_server.png
[5]ImgName: root/webp_serve
上面是在 5 处不同的地方 fmt.Println("ImgName", ImgName)
的输出,虽然我放了这么多,但是实际上在代码里 ImgName
在其作用域内只有过一次赋值,
ImgPath := c.Path() // ... 略去 10 行左右判断文件扩展名的代码 ImgName := path.Base(ImgPath)
然后就没写过了,仅有读的操作,没有任何赋值,中间只有一次被用来当作 Sprintf
的一个参数
WebpImgPath := fmt.Sprintf("%s/%s.%d.webp", DirPath, ImgName, ModifiedTime)
但显然这个也不会更改 ImgName
的内存嘛。“这不科学!” 虽然想这么叫出来,但是想想这个肯定还是有原因的!
由于在 macOS 上正常,在 Linux 下会出现这样的 issue(如下图),于是我首先想到的可能性是 —— 这个项目依赖的 WebP 处理的库里面有 C 代码,可能是在 Linux 下存在访问越界之类的,导致 Golang 内部记录的数据出问题了这样。然而在我继续增加 fmt.Println
输出缩小范围之后,发现 ImgName
是在 c.SendFile()
之后被修改的 (´・ω・`)
Emmmmm,这个 c.SendFile()
看起来完完全全没有用到 ImgName
的说,而且理论上应该也就是读取文件,然后写到 socket 里,不应该存在什么内存问题吧……?
嘛,毕竟是第三方库,万一有什么神秘操作呢? 因此这里就拿出了 GoLand 来 debug,在 c.SendFile()
处下断点,然后跟进去w
在不断 Step Into 之后,终于找到了问题的根源!其调用栈大致如下
8. [fasthttp/http.go] fasthttp.(*Request).URI() 7. [fasthttp/server.go] fasthttp.(*RequestCtx).URI() 6. [fasthttp/server.go] fasthttp.(*RequestCtx).Path() 5. [fasthttp/fs.go] fasthttp.(*fsHandler)handleRequest() 4. [fasthttp/fs.go] fasthttp.(*fsHandler)handleRequest-fm() 3. [fasthttp/fs.go] fasthttp.ServeFile() 2. [fiber/response.go] fiber.(*Ctx).SendFile() 1. [webp_server.go] c.SendFile()
这里 fiber 和 fasthttp 拿着一个 rw & shared 的 ctx
到处传,在用户调用 c.SendFile()
的时候,fasthttp 的 fsHandler.handleRequest()
会再次拿 ctx.Path()
。之后 ctx.Path()
会获取 ctx.URI().Path()
,但是在其第一步获取 URI
之后,ImgName
就被修改掉了 qwq
好,这个时候你可能要问了,为什么 URI 会被修改掉呢?而且出现的值还是本地路径(去掉了 leading slash,长度等于原本的 URI 的字符数)
那么回到 webp_server.go
,看到一开始的
ImgPath := c.Path() // ... 略去 10 行左右判断文件扩展名的代码 ImgName := path.Base(ImgPath)
ImgName
是从 path.Base(ImgPath)
来的,同时,我们发现调用 ctx.URI()
会导致 ImgName
的改变。那么必然 path.Base()
返回的是一个 ImgPath
的 slice,并且又因为 ImgPath
本身是 c.Path()
share 出来的,是可变的,所以 ImgName
也变了~
因此,可以推测,在调用 c.SendFile(WebpAbsolutePath)
的时候,fiber 或者 fasthttp 将 ctx.URI()
里的 uri
设置为了 WebpAbsolutePath
,从而导致 c.Path()
变化,也就是 ImgPath
变化,而 ImgName
又是 ImgPath
的 slice,所以最后就爆炸了。
根据推理,找到了问题的源头 ——
破案了!就是这一步 ctx.Request.SetRequestURI(path)
导致了前面离奇的问题。
一个本来很简单很简单的功能增加,没想到居然能搞得这么麻烦? 估计是 fasthttp 团队根本就没考虑到用户在 ServeFile
之后可能还会用到之前的 URI 吧。把内部的数据直接传出来而不 copy,fast
的目的倒是达到了,然而在经过 fiber 第二次包装后,最后的开发者就很难知道这一点了。
最后不得不说,在写了一段时间的 Rust 之后,再回头来看这样的、随便哪儿都可以修改各种内存的语言时,真的在某些方面还是觉得 Rust 是很棒的语言。如果按照 Rust 的游戏原则的话,这里除非瞎搞,否则应该不会出现这样莫名其妙的问题。(或者也不叫莫名其妙,但就是一旦遇到了,这个内存的值在哪儿被修改的都不好找)
一顿操作猛如虎,然后就诞生了一篇博客 ε=ε=ε=(~ ̄▽ ̄)~
一顿操作下来,发现自己曾经相信科学(x