记 Golang 下遇到的一回「毫无由头」的内存更改

前两天看到 @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 的游戏原则的话,这里除非瞎搞,否则应该不会出现这样莫名其妙的问题。(或者也不叫莫名其妙,但就是一旦遇到了,这个内存的值在哪儿被修改的都不好找)

2 thoughts on “记 Golang 下遇到的一回「毫无由头」的内存更改”

Leave a Reply

Your email address will not be published. Required fields are marked *

16 + six =