编译 | Tina、核子可乐
现在是2024年,昨天Leap Day Bugs又来了,估计又有一些团队迫于压力熬夜改bug了。
2 月 29 日下午,有消息称禾赛科技激光雷达存在固件 bug,致使凡是用了禾赛激光雷达的车,自动驾驶功能全部歇菜。
禾赛科技是激光雷达头部企业,其激光雷达交付量成功突破 5 万大关,成为了全球车载激光雷达行业首个单月交付量突破 5 万的公司。对此,有媒体向禾赛科技官方求证,禾赛科技回应称:“有 2 个老款 L4 机械式激光雷达今天出现了软件 bug。目前,问题原因已经找到,我们也跟相关客户都做了深入沟通、并提供了相关解决方案。”
据称该 bug 是个闰年问题。闰年是指该年有 366 日,即较平常年份多出一日。闰年是为了弥补因人为历法规定的年度天数 365 日和平均回归年的大约 365.24219 日的差距而设立的。多出来的一天为 2 月 29 日。也就是说今年的 3 月 1 日晚了 24 小时,这种情况每四年发生一次。对于开发者来说闰年是一次小考验,它强制要求大家必须在应用程序中考虑少见但不可避免的事件。
昨天,据禾赛科技表示他们“预计该问题会 24 小时内彻底解决。”
“24 小时”,说长不长,说短也不短,但对程序员来说,这可能是要求他们通宵达旦、爆肝代码的节奏。
而昨天因日历上的小小变化而造成软件 bug 和中断问题的不止禾赛科技一家。虽然四年前才刚发生过一次,但显然到现在还有很多公司没有做好准备。
我们首先得点名的是“OpenAI”。
多位网友反馈 OpenAI ChatGPT 3.5 认为“2024-02-29”不是有效日期。由于此问题,至少有一名 OpenAI API 用户在自己的应用程序中遇到了故障:
“我们有一个通过 API 使用 ChatGPT 的产品,使用的是 3.5 Turbo 版本。我们的查询涉及一些日期。今天它没有像通常那样返回文本,而是一直给出错误。”
新西兰多处加油站遭遇自助支付终端问题。据《新西兰先驱报》报道:
“该问题影响了全国所有无人值守的加油站,因为新西兰所有燃料公司都使用一家技术提供商 Invenco。原因是该系统未处理 2 月 29 日这一日期。在经历了长达一天的闰年故障(刷卡支付机停机了 10 多个小时)之后,全国各地的加油站已重新恢复运行。”
“我们清楚地知道闰年,”Invenco 首席执行官约翰·斯科特 (John Scott) 说。“过去 20 到 30 年来我们一直在与它们打交道。”
哥伦比亚最大的航空公司打印的机票有误。阿维安卡航空公司 (Avianca) 打印的机票日期为 3/1,而不是 2/29,因为他们的系统没有考虑闰日。一位旅客分享了该航空公司向客户发送的电子邮件:
“我们通知您,如果您的航班日期为 2024 年 2 月 29 日,您的登机牌上的航班日期可能会存在差异。为了确保您获得正确的信息,请从 avianca.com 或我们的应用程序重新下载。”
印度新发布的智能手表无法显示正确的日期。Fastrack FS1 是印度公司 Fatrack 最近发布的一款智能手表。FS1 型号于 2023 年 3 月发布。有多份报告称该款手表在 2 月 28 日晚 11:59 后不再继续跳动。
Fastrack 已经承认存在故障,并表示正在努力修复。但显然这个问题花了 8 个小时还没得到解决。
有用户无法购买 YouTube Premium 订阅。年龄验证逻辑认为他们未满 18 岁,因为他们是在闰日出生的。这位用户发帖称,如果按照 YouTube Premium 计算方法,他们需要等到 70 岁之后才能够购买。
EA Sports 赛车游戏崩溃了。EA SPORTS WRC(世界拉力锦标赛)是一款拉力赛车游戏,于 2023 年 11 月发布,适用于 Windows、Xbox 和 Playstation。今天这个游戏显然玩不了了:因为它崩溃了。
鉴于游戏行业比其他大多数公司在游戏质量保证和测试方面投入更多,这次崩溃着实有点让人难以理解。
EA Sports 建议的解决方法是“将你的系统日期设置为 3 月 1 日 ,或者今天就休息一下!”
这个解决方案简直是太出乎意料了,但也不是人人都打算忽视这个问题。有些开发者还是在认真修复这个 bug 的,对着这些开发者,我们借用网友的话来说,就是“值得致敬”!
这个 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 结构成为常见的民用时间表示方式。它会将日期中的各个部分设为不同的字段,具体分隔为年、月、日值(及其他值)。下面来看常见的代码表示:
上述代码能够顺利运行,不会报出任何错误。但风险在于,如果在 2 月 29 日调用代码,则结果值仍将是 2 月 29 日,但结果年很可能并非闰年。例如 2016-02-29 + 1 year = 2017-02-29,而 2017 年根本就没有 2 月 29 号。
在最终被作为另一项函数(例如 SystemTimeToFileTime)的参数之前,这个值可能会被传递多次,这会导致函数失败并返回零值。遗憾的是,很多方法都会直接使用上述代码,而根本不对返回值进行检查。这可能会引发无法预测的结果,例如将的 FILETIME 值保留为未初始化状态。
请注意,标准 C++(非 Windows)代码中也可能存在类似的 bug。这里使用 tm 结构替代 SYSTEMTIME,因此具体操作略有不同。该结构中的月份值为 0 到 11,而非 1 到 12,因此二月被标记为 month 1。大家可以调用 _mkgmtime 来生成 time_t 结构,而非 SystemTimeToFileTime。二者的关键区别在于,tm 结构在运行至非闰年的 2 月 29 日不会报错,而是直接生成代表 3 月 1 日的值。因此如果应用软件计划于 2 月 28 日截止,则需要进行调整。
#2: 为一年中每一天的值声明一个数组
以上 C 代码可以轻松使用 C# 或者其他语言重写,也可以使用字符串或者其他某种数据类型替换整数。其中的关键,在于我们会声明一个固定大小的数组来保存数据,并假设一年中的每一天在数组中都有相应的单一位置。相信大家已经看出问题了,在闰年中,数组无法给第 366 天(12 月 31 日)留出位置。
由此产生的后果视编程语言而定。在 C# 中,这会引发 IndexOutOfRangeException 异常。在 C 语言中,除非启用了边界检查编译器选项,否则这会导致缓冲区溢出——具体影响也就可大可小了。Java 开发才倒是不用担心,因为语言会自动添加第 366 个元素。
数据过滤问题
闰年 bug 还会造成其他影响,比如影响到上一年 2 月 29 日到次年 3 月 1 日之间的任意数据。这种影响通常体现在数据过滤当中,比如范围查询不会考虑到额外的闰日——假设一年始终只有 365 天,或者假设 2 月始终只有 28 天。我们以下面的 SQL 语句为例:
这条查询很好,但如果把其中的 @enddate 设定为今天,再把 @startdate 设置为今年再减去 365 天,结果会如何。假设该范围内恰好包含 2 月 29 日闰日,那它就无法涵盖一整年。具体来讲,开始日期少了一天,所以过滤得出的值不正确(假设用户就是想筛出过去一整年的数据)。
在评估此类 bug 时,我们首先需要考虑 bug 的实际影响。具体来说,这些值会显示在哪里?如果系统只是每天把平均订单金额更新到仪表板上的图表当中,那造成的影响肯定不会像公司财务报告(比如上报给证券交易委员会的文件)中的当年总销售额那么重要。当然,bug 评估肯定要求大家熟悉应用软件及其用法,所以实际操作还是要由各位灵活调整。
这里我们推荐下面这种行之有效的解决方法:
但这种方法也有其缺陷。仅通过评估年份,是无法确定具体需要添加多少天的。毕竟 endDate 有可能只是 2016-01-01,所以尽管 2016 年是闰年,但只需减去 365 天就能得到 2015-01-01。也就是说,我们还得考虑 2 月 29 日闰日是否被包含在范围之内。如果尝试手动执行,就得使用不少相当复杂的代码。而且跨越的年数越多,具体实现就越麻烦。
究其根本,.NET 中的 TimeSpan(包括其他语言中的相似类型)表示的都是绝对时间,其中“年”和“月”属于民用时间单位。一年或一个月的绝对时间量,将根据开发者描述的年份或月份而有所变化。(夏令时甚至对一天的定义都有浮动,但这就不在本文的讨论范围内了。)
.NET 上的正确解决方案是:
这里的 AddYears 方法正确实现了所有必要逻辑,可以确定要向未来移动多少天,或者在取负值时代表向过去移动多少天。
在 Java 中添加年份
Java 开发者应该使用 moment.js 来实现这项功能,而且非常简单:
但有些人偏喜欢用更麻烦的方法行事,所以我们也经常会看到下面的方法:
这里的问题前文已经提到了。如果今天是闰年的 2 月 29 日,则结果值将为 3 月 1 日——可能有影响,也可能没啥影响。毕竟对于其他所有日期来说,结果都跟原始值处于同一个月内。但请注意,如果你的应用软件对月底和月初非常敏感,那就不行。
这里大家可以使用以下函数在 Java 正确添加年份,而无需调用完整库:
d.setFullYear(d.getFullYear + n);
if(d.getMonth !== m)
d.setDate(d.getDate - 1);}
// 用法示例vard = newDate;addYears(d, 1);
这就实现了添加年份,之后会检查是否发生了转至三月的情况。如果发生,则做出调整。再次强调,千万不要具体计算需要添加的天数来解决问题——那更容易出错,除非你真的很有经验、清醒地知道自己在干什么。
其他常见错误
开发人员曾犯下过很多跟闰年相关的错误,例如:
很多朋友还经常提到另外两种方法:
静态代码分析
如果有一组工具可以针对现有代码运行,并指出哪里存在闰年 bug,那可就太棒了!但遗憾的是,我们还没听说过这样的工具。唯一能想到的,也就是简单的字符串搜索或者正则表达式搜索了。
.NET 真正需要的是一套全面的 Roslyn 分析器,它可以捕捉常见的日期 / 时间 bug,包括闰年、时区、夏令时、解析等。
同样的道理也适用于 C++、Java 和其他编程语言——大家都需要,但就是没有。
时间调节
为什么不把时间快进到下一个闰日,看看结果如何?在某些系统上,这样确实可行。但其同样存在一些问题。
所以总的来说,我们建议大家不要耍这种小聪明。
模拟时钟
那该如何正确测试代码在不同日期下是否表现有别?答案就是模拟时钟。
这也是许多可靠系统中的常见模式。再次强调,用于显示当前真实时间的系统时钟绝不可随意使用。应用程序的逻辑永远不该直接调用 DateTime.Now、DateTime.UtcNow、new Datte、GetSystemTime 或者编程语言中任何同类项来获取当前日期和时间。
相反,我们应该将时钟视为一项服务(在 DDD 领域驱动设计意义上);而且跟任何服务一样,大家必须有办法模拟时钟。
举例来说,在.NET 中,不要从应用程序逻辑处直接调用 DateTimeOffset.UtcNow(或者类似的 API):
上面这一系列步骤听起来有点麻烦,但只要顺利完成,大家就能感受到它的优势所在。这意味着当前日期和时间都是依赖项,这也是保证所有代码都能受测试覆盖的唯一方法。
这里我们没有提供具体代码,因为在不同的编程语言中肯定有不同的实现,但思路和模式应该是共通的。另外,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/
拜登:“一切非 Rust 项目均为非法”
12 分钟内部会结束了苹果十年造车梦,转攻 AIGC!数十亿美元打了水漂、2000 员工或转岗或被裁
沉寂 600 多天后,React 憋了个大招
三人团队,七天“不眠不休”,我们赶在 Vision Pro 发布的那一刻做出了一款头显应用
活动推荐
为了提供更丰富多元的交流平台,QCon 全球软件开发大会将不再局限于传统的分享与研讨模式,而是全面整合为集技术分享、深度研讨和前沿展览于一体的综合性会展活动,并正式更名为【QCon 全球软件开发大会暨智能软件开发生态展】。
同时,会议正式改期为:2024 年 4 月 11-13 日,地点:北京·国测国际会议会展中心。
目前会议已进入 8 折早鸟购票阶段,联系票务经理 17310043226 。同时,3 月 1 日(本周五)关于「会展」新模式的首场直播也将闪亮登场,点击下方卡片进行预约。查看「 阅读原文」可了解大会更多详情,期待与各位开发者现场交流。