![致敬昨晚熬夜改bug的技术团队!连OpenAI也躲不过:为什么几行代码能反复干翻大批软件](https://static001.infoq.cn/resource/image/1c/a9/1cdf431b1ae93c19741a809a25d7dca9.jpeg)
现在是 2024 年,昨天 Leap Day Bugs 又来了,估计又有一些团队迫于压力熬夜改 bug 了。
2 月 29 日下午,有消息称禾赛科技激光雷达存在固件 bug,致使凡是用了禾赛激光雷达的车,自动驾驶功能全部歇菜。
![](https://static001.geekbang.org/infoq/0f/0f6a3f81ee337b827c1f3eea4cec4ddf.png)
禾赛科技是激光雷达头部企业,其激光雷达交付量成功突破 5 万大关,成为了全球车载激光雷达行业首个单月交付量突破 5 万的公司。对此,有媒体向禾赛科技官方求证,禾赛科技回应称:“有 2 个老款 L4 机械式激光雷达今天出现了软件 bug。目前,问题原因已经找到,我们也跟相关客户都做了深入沟通、并提供了相关解决方案。”
据称该 bug 是个闰年问题。闰年是指该年有 366 日,即较平常年份多出一日。闰年是为了弥补因人为历法规定的年度天数 365 日和平均回归年的大约 365.24219 日的差距而设立的。多出来的一天为 2 月 29 日。也就是说今年的 3 月 1 日晚了 24 小时,这种情况每四年发生一次。对于开发者来说闰年是一次小考验,它强制要求大家必须在应用程序中考虑少见但不可避免的事件。
![](https://static001.geekbang.org/infoq/78/781de5a71e1507aaecc01bfebaf8974b.jpeg)
![](https://static001.geekbang.org/infoq/ae/ae9d3a573d91eda2a3e1357ed4ff8d11.jpeg)
昨天,据禾赛科技表示他们“预计该问题会 24 小时内彻底解决。”
“24 小时”,说长不长,说短也不短,但对程序员来说,这可能是要求他们通宵达旦、爆肝代码的节奏。
而昨天因日历上的小小变化而造成软件 bug 和中断问题的不止禾赛科技一家。虽然四年前才刚发生过一次,但显然到现在还有很多公司没有做好准备。
我们首先得点名的是“OpenAI”。
多位网友反馈 OpenAI ChatGPT 3.5 认为“2024-02-29”不是有效日期。由于此问题,至少有一名 OpenAI API 用户在自己的应用程序中遇到了故障:
“我们有一个通过 API 使用 ChatGPT 的产品,使用的是 3.5 Turbo 版本。我们的查询涉及一些日期。今天它没有像通常那样返回文本,而是一直给出错误。”
![](https://static001.geekbang.org/infoq/ca/ca3927dbe912320354999ab194fbfcbe.jpeg)
新西兰多处加油站遭遇自助支付终端问题。据《新西兰先驱报》报道:
“该问题影响了全国所有无人值守的加油站,因为新西兰所有燃料公司都使用一家技术提供商 Invenco。原因是该系统未处理 2 月 29 日这一日期。在经历了长达一天的闰年故障(刷卡支付机停机了 10 多个小时)之后,全国各地的加油站已重新恢复运行。”
“我们清楚地知道闰年,”Invenco 首席执行官约翰·斯科特 (John Scott) 说。“过去 20 到 30 年来我们一直在与它们打交道。”
哥伦比亚最大的航空公司打印的机票有误。阿维安卡航空公司 (Avianca) 打印的机票日期为 3/1,而不是 2/29,因为他们的系统没有考虑闰日。一位旅客分享了该航空公司向客户发送的电子邮件:
![](https://static001.geekbang.org/infoq/68/688f0d189b7b96cd772cc79141324fb3.jpeg)
“我们通知您,如果您的航班日期为 2024 年 2 月 29 日,您的登机牌上的航班日期可能会存在差异。为了确保您获得正确的信息,请从 avianca.com 或我们的应用程序重新下载。”
印度新发布的智能手表无法显示正确的日期。Fastrack FS1 是印度公司 Fatrack 最近发布的一款智能手表。FS1 型号于 2023 年 3 月发布。有多份报告称该款手表在 2 月 28 日晚 11:59 后不再继续跳动。
![](https://static001.geekbang.org/infoq/a1/a139107768cb81f2dda16a4807b56446.png)
Fastrack 已经承认存在故障,并表示正在努力修复。但显然这个问题花了 8 个小时还没得到解决。
![](https://static001.geekbang.org/infoq/17/17343de15ea1a82af0288810c28b90ee.jpeg)
有用户无法购买 YouTube Premium 订阅。年龄验证逻辑认为他们未满 18 岁,因为他们是在闰日出生的。这位用户发帖称,如果按照 YouTube Premium 计算方法,他们需要等到 70 岁之后才能够购买。
![](https://static001.geekbang.org/infoq/4b/4bc1ee27607b16675c8d56a3d47242f1.jpeg)
日本驾驶执照发放中断。《今日日本》 写道:
周四,日本一些县的警察部门无法颁发驾驶执照,这显然是由于与闰年相关的系统故障。二月底的额外一天似乎触发了与时间和日期函数相关的系统错误。
新泻县、神奈川县、冈山县和爱媛县的警察部门从早上开始就面临问题,例如无法将个人数据从驾驶员管理系统发送到颁发许可证的机器。中午之前系统几乎完全恢复。
“中午之前几乎完全恢复”的说法简直绝了,试想一下,一位工程师被业务利益相关者焦急地问到:“修复好了吗?”工程师回答:“几乎修好了,我已经解决了 90%的问题。”然而,我们都知道,这说法背后,还隐藏着不少挑战:解决最后的 10%问题往往需要与修复前 90%问题一样多的时间和精力。
EA Sports 赛车游戏崩溃了。EA SPORTS WRC(世界拉力锦标赛)是一款拉力赛车游戏,于 2023 年 11 月发布,适用于 Windows、Xbox 和 Playstation。今天这个游戏显然玩不了了:因为它崩溃了。
鉴于游戏行业比其他大多数公司在游戏质量保证和测试方面投入更多,这次崩溃着实有点让人难以理解。
![](https://static001.geekbang.org/infoq/df/df382abee29531a7c0d5a263f9367389.png)
EA Sports 建议的解决方法是“将你的系统日期设置为 3 月 1 日 ,或者今天就休息一下!”
这个解决方案简直是太出乎意料了,但也不是人人都打算忽视这个问题。有些开发者还是在认真修复这个 bug 的,对着这些开发者,我们借用网友的话来说,就是“值得致敬”!
![](https://static001.geekbang.org/infoq/3a/3a72aca65e0aa9c808f1da5671bd0684.jpeg)
![](https://static001.geekbang.org/infoq/71/71293ba0b3474f688f055397ef5c10ff.jpeg)
这个 bug 怎么修?
过往的闰年已经闹出过不少影响巨大、引人注目的 bug。
例如:2012 年微软 Azure 曾遭遇中断,证书到期日期的计算错误致使服务中断达 12 个小时。2010 年索尼 PlayStation 网络中断的根源,正是系统将 2010 年错误识别成了闰年。2008 年微软 Zure 设备集体“变砖”,罪魁祸首就是 12 月 31 日逻辑错误。2008 年微软 Exchange 管理 bug 导致管理员在 2 月 29 日无法执行大部分操作。Lotus 1-2-3 对 1900 年的计算错误,直到 30 多年后的今天也仍是笼罩在微软 Excel 头顶的阴影!
这些还都是登上头条的大新闻,我们相信肯定还有不计其数的小问题也曾发生,并在不同程度上影响到很多无辜用户和项目开发者。
闰年 bug 随处可见,但在 C/C++代码中惹出的麻烦最大,可能导致应用程序崩溃或者缓冲区溢出(已经构成安全风险)。
危险性最高的两大闰年 bug
#1: 在 C / C++中添加或减去年份
在使用 Win32 API 的 C/C++代码当中,SYSTEMTIME 结构成为常见的民用时间表示方式。它会将日期中的各个部分设为不同的字段,具体分隔为年、月、日值(及其他值)。下面来看常见的代码表示:
SYSTEMTIME st; // 声明一个 SYSTEMTIME 变量
GetSystemTime(&st); // 将其设置为当前日期和时间
st.wYear++; // 将值增加一年
上述代码能够顺利运行,不会报出任何错误。但风险在于,如果在 2 月 29 日调用代码,则结果值仍将是 2 月 29 日,但结果年很可能并非闰年。例如 2016-02-29 + 1 year = 2017-02-29,而 2017 年根本就没有 2 月 29 号。
在最终被作为另一项函数(例如SystemTimeToFileTime)的参数之前,这个值可能会被传递多次,这会导致函数失败并返回零值。遗憾的是,很多方法都会直接使用上述代码,而根本不对返回值进行检查。这可能会引发无法预测的结果,例如将的 FILETIME 值保留为未初始化状态。
请始终检查 Win32 函数的状态结果,特别是 SystemTimeToFileTime。
检查结果是否有效并在必要时进行调整,保证正确向 SYSTEMTIME 添加一年:
SYSTEMTIME st; // 声明一个 SYSTEMTIME 变量
GetSystemTime(&st); // 将其设置为当前日期和时间
st.wYear++; // 将值增加一年
// 检查是否为闰年
bool leap = st.wYear % 4 == 0 && (st.wYear % 100 != 0 || st.wYear % 400 == 0);
// 如果值为 2 月 29 日,但并非闰年,则回移至 2 月 28 日
st.wDay = st.wMonth == 2 && st.wDay == 29 && !leap ? 28 : st.wDay;
请注意,标准 C++(非 Windows)代码中也可能存在类似的 bug。这里使用 tm 结构替代 SYSTEMTIME,因此具体操作略有不同。该结构中的月份值为 0 到 11,而非 1 到 12,因此二月被标记为 month 1。大家可以调用_mkgmtime 来生成 time_t 结构,而非 SystemTimeToFileTime。二者的关键区别在于,tm 结构在运行至非闰年的 2 月 29 日不会报错,而是直接生成代表 3 月 1 日的值。因此如果应用软件计划于 2 月 28 日截止,则需要进行调整。
#2: 为一年中每一天的值声明一个数组
int items[365];
items[dayOfYear - 1] = x;
以上 C 代码可以轻松使用 C#或者其他语言重写,也可以使用字符串或者其他某种数据类型替换整数。其中的关键,在于我们会声明一个固定大小的数组来保存数据,并假设一年中的每一天在数组中都有相应的单一位置。相信大家已经看出问题了,在闰年中,数组无法给第 366 天(12 月 31 日)留出位置。
由此产生的后果视编程语言而定。在 C#中,这会引发 IndexOutOfRangeException 异常。在 C 语言中,除非启用了边界检查编译器选项,否则这会导致缓冲区溢出——具体影响也就可大可小了。JavaScript 开发才倒是不用担心,因为语言会自动添加第 366 个元素。
数据过滤问题
闰年 bug 还会造成其他影响,比如影响到上一年 2 月 29 日到次年 3 月 1 日之间的任意数据。这种影响通常体现在数据过滤当中,比如范围查询不会考虑到额外的闰日——假设一年始终只有 365 天,或者假设 2 月始终只有 28 天。我们以下面的 SQL 语句为例:
SELECT AVG(Total) as AverageOrder, SUM(Total) as GrandTotal
FROM Orders WHERE OrderDate >= @startdate AND OrderDate < @enddate
这条查询很好,但如果把其中的 @enddate 设定为今天,再把 @startdate 设置为今年再减去 365 天,结果会如何。假设该范围内恰好包含 2 月 29 日闰日,那它就无法涵盖一整年。具体来讲,开始日期少了一天,所以过滤得出的值不正确(假设用户就是想筛出过去一整年的数据)。
在评估此类 bug 时,我们首先需要考虑 bug 的实际影响。具体来说,这些值会显示在哪里?如果系统只是每天把平均订单金额更新到仪表板上的图表当中,那造成的影响肯定不会像公司财务报告(比如上报给证券交易委员会的文件)中的当年总销售额那么重要。当然,bug 评估肯定要求大家熟悉应用软件及其用法,所以实际操作还是要由各位灵活调整。
这里我们推荐下面这种行之有效的解决方法:
TimeSpan oneYear = TimeSpan.FromDays(isLeapYear(endDate.Year) ? 366 : 365);
DateTime startDate = endDate - oneYear;
但这种方法也有其缺陷。仅通过评估年份,是无法确定具体需要添加多少天的。毕竟 endDate 有可能只是 2016-01-01,所以尽管 2016 年是闰年,但只需减去 365 天就能得到 2015-01-01。也就是说,我们还得考虑 2 月 29 日闰日是否被包含在范围之内。如果尝试手动执行,就得使用不少相当复杂的代码。而且跨越的年数越多,具体实现就越麻烦。
究其根本,.NET 中的 TimeSpan(包括其他语言中的相似类型)表示的都是绝对时间,其中“年”和“月”属于民用时间单位。一年或一个月的绝对时间量,将根据开发者描述的年份或月份而有所变化。(夏令时甚至对一天的定义都有浮动,但这就不在本文的讨论范围内了。)
.NET 上的正确解决方案是:
DateTime startDate = endDate.AddYears(-1);
这里的 AddYears 方法正确实现了所有必要逻辑,可以确定要向未来移动多少天,或者在取负值时代表向过去移动多少天。
在 JavaScript 中添加年份
JavaScript 开发者应该使用 moment.js 来实现这项功能,而且非常简单:
var m = moment();
m.add(1, 'years');
但有些人偏喜欢用更麻烦的方法行事,所以我们也经常会看到下面的方法:
var d = new Date();
d.setFullYear(d.getFullYear() + 1);
这里的问题前文已经提到了。如果今天是闰年的 2 月 29 日,则结果值将为 3 月 1 日——可能有影响,也可能没啥影响。毕竟对于其他所有日期来说,结果都跟原始值处于同一个月内。但请注意,如果你的应用软件对月底和月初非常敏感,那就不行。
这里大家可以使用以下函数在 JavaScript 正确添加年份,而无需调用完整库:
function addYears(d, n) {
var m = d.getMonth();
d.setFullYear(d.getFullYear() + n);
if (d.getMonth() !== m)
d.setDate(d.getDate() - 1);
}
// 用法示例
var d = new Date();
addYears(d, 1);
这就实现了添加年份,之后会检查是否发生了转至三月的情况。如果发生,则做出调整。再次强调,千万不要具体计算需要添加的天数来解决问题——那更容易出错,除非你真的很有经验、清醒地知道自己在干什么。
其他常见错误
开发人员曾犯下过很多跟闰年相关的错误,例如:
弄错了闰年算法。闰年绝对不是固定每四年一次,对于不能被 100 整除的年份才是每四年一次,能被 400 整除的除外。也就是说,1900 年并不是闰年。
为每个月使用天数数组,其中二月只有 28 天。使用此类数组时,必须考虑闰年的第 29 天。更好的办法当然是为闰年创建一套跟平年不同的数组,而一步到位的答案则是直接使用 API(如果可行),尽量别自己亲自计算。
针对闰年为代码创建分支,但没有测试所有代码路径。例如,Zune bug 的代码顶部就有一个 ISleapYear(year)分支,但微软显然从来没测试过该分支。
使用单独的年、月和日值,但却不对其进行验证。例如,我们可能有一个带有单独下拉菜单控件的 UI,用于选定每个组件。只测试某个日期在特定月份内是否有效还不够,我们还得把年份也考虑进来。
直接使用一年的平均天数,比如日期数学中的 365.25 天或者 365.2425 天。虽然这在科学上比较准确,但却根本不适合民用时间惯例。毕竟大多数用例根本就不在乎日期的值取到小数点后几位。如果我们只需要一个近似值倒是没问题,但结果中的具体日期还是可能出错。
如何发现闰年 bug?
认真检查您的代码,搜索一切跟时间相关的内容,然后仔细梳理。
确保进行充分的单元测试,并且了解如何正确“模拟时钟”(我们会在下一节中具体讲解)。
全年测试,而非只在闰年之前测试。
验证所有输入,包括配置部分。
验证结果并完成场景,同时制定故障应对策略!
很多朋友还经常提到另外两种方法:
静态代码分析
如果有一组工具可以针对现有代码运行,并指出哪里存在闰年 bug,那可就太棒了!但遗憾的是,我们还没听说过这样的工具。唯一能想到的,也就是简单的字符串搜索或者正则表达式搜索了。
.NET 真正需要的是一套全面的 Roslyn 分析器,它可以捕捉常见的日期/时间 bug,包括闰年、时区、夏令时、解析等。
同样的道理也适用于 C++、Javascript 和其他编程语言——大家都需要,但就是没有。
时间调节
为什么不把时间快进到下一个闰日,看看结果如何?在某些系统上,这样确实可行。但其同样存在一些问题。
我们的单元测试可能仍然无法捕捉到所有问题。除非大家手动查看整个应用软件的每个屏幕和每份报告,否则很可能发现不了数据过滤 bug。没发现的 bug 就是雷,早晚会炸。
这可能会带来一种虚假的安全感,认为快进后没问题,那到时候也不会有问题。这种问题不止出现过一次,只有客户们在 2 月 29 日或者 3 月 1 日疯狂打电话投诉时,你才会意识到自己太傻太天真。
很多系统都必须使用域服务器进行身份验证,或者使用其他时间敏感的身份验证方案。所以这里要提醒大家,Kerberos 协议有着严格的时间同步要求,默认容差必须在 5 分钟之内。另外还有 SSL 证书、代码签名证书和一系列其他安全机制,它们全都依赖于时钟。所以如果试图谎报时间,系统就会报错。
所以总的来说,我们建议大家不要耍这种小聪明。
模拟时钟
那该如何正确测试代码在不同日期下是否表现有别?答案就是模拟时钟。
这也是许多可靠系统中的常见模式。再次强调,用于显示当前真实时间的系统时钟绝不可随意使用。应用程序的逻辑永远不该直接调用 DateTime.Now、DateTime.UtcNow、new Datte()、GetSystemTime 或者编程语言中任何同类项来获取当前日期和时间。
相反,我们应该将时钟视为一项服务(在 DDD 领域驱动设计意义上);而且跟任何服务一样,大家必须有办法模拟时钟。
举例来说,在.NET 中,不要从应用程序逻辑处直接调用 DateTimeOffset.UtcNow(或者类似的 API):
使用返回 DateTimeOffset 的方法 GetCurrentTime 来创建一个接口 IClock。
创建一个从 IClock 实现的 SystemClock 类,其中 GetCurrentTime 调用 DateTimeOffset.UtcNow。
创建一个从 IClock 实现的 FakeClock 类,该类接受固定值作为构造函数参数,且其中 GetCurrentTime 仅返回该固定值。
在应用程序逻辑中,应仅依赖于 IClock 接口,且通常由构造函数进行注入。
在测试中使用 FakeClock,而在运行时上接入 SystemClock。
上面这一系列步骤听起来有点麻烦,但只要顺利完成,大家就能感受到它的优势所在。这意味着当前日期和时间都是依赖项,这也是保证所有代码都能受测试覆盖的唯一方法。
这里我们没有提供具体代码,因为在不同的编程语言中肯定有不同的实现,但思路和模式应该是共通的。另外,Noda Time 中已经提供非常好找实现,它在主程序集中直接提供 IClock 和 SystemClock,在 NodeTime.Testing 程序集中则提供 FakeClock。所以如果大家实在担心闰年 bug,不妨直接使用 Noda Time。
闰年虽然不像当初的千年虫那样搞得举世震惊,但它无疑也是个刺头、而且每隔几年就跑出来恶心人。过去四年间大家写了多少代码?敢保证一切都符合标准吗?现在请花点时间扫描并测试自己的代码,没准会发现有些您没意识到的隐患就潜伏在阴影当中。
参考链接:
https://news.ycombinator.com/item?id=39556252
https://codeofmatt.com/list-of-2024-leap-day-bugs/
https://newsletter.pragmaticengineer.com/p/happy-leap-day
https://codeofmatt.com/happy-new-leap-year/
https://edition.cnn.com/interactive/2024/02/world/leap-year-meaning-explained-dg-scn/
评论