分类: programming

  • 排查 duckdb-node-neo 并发查询时的随机段错误

    问题的说明跟复现请看这里,这篇文章主要记录一下如何排查 node addon 代码引发的问题。

    保存进程崩溃的翻车现场

    在 Linux/KDE 环境下,当应用进程崩溃的时候,systemd 会记录下该进程的翻车现场(aka,coredump)。我们可以使用 coredumpctl 这个工具查看当前机器上发生过的 coredump。

      List all captured core dumps:
    
          coredumpctl
    
      List captured core dumps for a program:
    
          coredumpctl list program
    
      Show information about the core dumps matching a program with `PID`:
    
          coredumpctl info PID
    
      Invoke debugger using the last core dump:
    
          coredumpctl debug
    
      Invoke debugger using the last core dump of a program:
    
          coredumpctl debug program
    
      Extract the last core dump of a program to a file:
    
          coredumpctl --output path/to/file dump program
    
      Skip debuginfod and pagination prompts and then print the backtrace when using `gdb`:
    
          coredumpctl debug --debugger-arguments "-iex 'set debuginfod enabled on' -iex 'set pagination off' -ex bt"

    还原翻车现场

    通过上面的步骤获取到 coredump 后,可以搭配 debugger 工具跟 symbol file 定位出问题的代码。我选择的 debugger 工具是 gdb 搭配 CLion 作 UI。这样只要将 node addon 项目用 CLion 打开,并导入 coredump 后,就可以获得一个功能齐全的 GUI Debugger。

    用 CLion 打开 node addon 项目

    以 duckdb-node-neo 为例,这个项目使用 node-gyp 作为构建工具,而 CLion 只支持 CMAKE 工程,所以需要手动创建一份 CMakefile 文件,让 IDE 的智能提示正常工具,方便在 debug 的过程中查看定义、搜索引用等。

    下面的这份 CMake 文件是我跟 Claude 一起编写的,不一定能够通过 IDE 编译 node addon,但是可以提供智能补全。

    cmake_minimum_required(VERSION 3.10)
    project(duckdb_node_neo)
    
    set(CMAKE_CXX_STANDARD 17)
    
    # include node headers
    include_directories(/usr/include/node)
    
    # Node.js addon API headers
    include_directories(${CMAKE_SOURCE_DIR}/bindings/node_modules/node-addon-api)
    
    # DuckDB headers
    include_directories(${CMAKE_SOURCE_DIR}/bindings/libduckdb)
    
    # Add source files
    file(GLOB SOURCE_FILES
        "bindings/src/*.cpp"
    )
    
    # This is just for CLion's code intelligence - not for actual building
    add_library(duckdb_node_bindings SHARED ${SOURCE_FILES})
    
    # Define preprocessor macros used in the code
    add_definitions(-DNODE_ADDON_API_DISABLE_DEPRECATED)
    add_definitions(-DNODE_ADDON_API_REQUIRE_BASIC_FINALIZERS)
    add_definitions(-DNODE_API_NO_EXTERNAL_BUFFERS_ALLOWED)
    
    # Add Node.js specific definitions to help CLion's IntelliSense
    add_definitions(-DNAPI_VERSION=8)
    
    # Tell CLion to treat this as a Node.js addon project
    set_target_properties(duckdb_node_bindings PROPERTIES
        CXX_STANDARD 17
        PREFIX ""
        SUFFIX ".node"
    )

    生成带有 symbol 的二进制

    设置后 debugger 后,还需要提供一个 symbol 文件,帮助 debugger 定位代码位置。对于 node-gyp 而言,只需要在编译时设置 --debug 参数即可生成带有 symbol 的二进制。

    cross-replace node-gyp configure --verbose --arch=$TARGET_ARCH --debug && node-gyp build --verbose --arch=$TARGET_ARCH --debug

    接下来我们只要用带有 symbol 的二进制复现进程崩溃的问题,就可以得到一个方便 debug 的 coredump。

    定位问题

    通过 debug coredump 文件,可以很容易找到抛出错误的地方,但这次很不走运的是, error 变量是 NULL,没有提供任何有价值的信息。另外,可以发现 result_ptr_ 似乎处于一个没有被初始化的状态。

    查看 duckdb 的源码后发现,虽然 duckdb 对外提供的是 C API,但是其本身是用 C++ 开发的,在将 C++ 操作以 C API 的形式暴露时,duckdb 会捕获内部的异常,然后将其中的错误信息写入到 duckdb_result 中。

    目前 coredump 表明 result_ptr_ 未被 duckdb 正常初始化,而异常对象很可能已经在跳出 catch 作用域时被销毁,现在只能在运行时 debug 才能捕获到具体的错误信息了。

    附加到进程上 debug

    CLion 提供了非常好用的附加到未启动进程上 debug 的功能,只需要向 CLion 提供进程的 commandline,它就可以自动将 debugger 附加到该进程上。

    在开始 debug 之前,还需要设置一下 C++ 异常断点:

    当启动问题进程,并复现崩溃后,就可以在 debugger 视图查看到实时的异常对象信息:

    这里的 obj 就是异常对象,但是由于 CLion 没有识别出异常的类型,我们得手动用 gdb 指令查看 obj 的信息。

    通过 gdb 的输出,可以看到异常对象的类型是 duckdb::InvalidInputException

    这时在变量页面输入下面的指令就行:

    *(duckdb::InvalidInputException*)obj

    终于,更加具体的错误信息被挖掘出来了~

  • 跟 LLM 一起折腾 Web Audio

    最近在开发一个与 TTS(Text-to-Speech)相关的功能,其中需要对播放的人声音频进行加速或减速处理。这个领域我之前完全没有接触过,DeepSeek 推荐我使用 Howler.js 和 Tone.js 这样的库来播放音频。

    在实现加速功能时,我发现需要修改 playbackRate,但通常 playbackRate 和音调(Pitch)的修改是联动的。然而,Howler.js 并没有提供直接修改 Pitch 的方法。在折腾了很久之后,DeepSeek 也没能解决 Tone.js 带来的噪音问题。使用在线处理(Pitch Shift 效果器)时,会产生“dadadada”的噪音;而使用离线处理(GrainPlayer)时,又会出现类似回声的杂音。

    最终,我查到 HTML5 的 Audio Element 提供了 preservePitch 的功能,这似乎是一个比较理想的解决方案。

    一些经验总结

    1. 代码生成与抽象
      即使通过 LLM 生成代码的成本较低,也尽量让它生成易于拓展和复用的代码。在我的尝试中,即便切换了多个不同的音频库,我都不需要修改业务代码,这正是因为我在最初就让 LLM 对音频处理部分进行了足够的抽象。
    2. LLM 在处理开放性问题时的局限性
      如果待解决的开放性问题足够普遍,LLM 通常能给出令人满意的答案。但对于像 Web Audio 这样 API 复杂且不太常见的技术领域,LLM 可能就很难解决问题了。例如,在解决 Pitch Shift 效果器的噪音问题时,DeepSeek 很难坚定地指出 Tone.js 的实现本身就存在缺陷(事实上也是如此)。这大概率是因为 LLM 只是一个互联网文本预测器,容易产生“幻觉”,尤其是在处理小众领域的问题时。

    总结

    在开发过程中,抽象和代码复用是非常重要的,尤其是在处理新技术领域时。虽然 LLM 在很多常见问题上表现出色,但在处理复杂或小众领域的问题时,仍需要结合人工经验和深入的技术调研来找到更优的解决方案。

  • 工欲善其事,如何利其器?CLI 篇

    工欲善其事,必先利其器,这篇文章将从 CLI 工具这个方面向你介绍“如何提高在 CLI 环境下的工作效率”。

    Shell

    Shell 是一个用来提供与操作系统内核(Kernel)交互的软件。

    常见的 Shell 分为两种:

    • Command Line Interface (CLI) 命令行界面
    • Graphic User Interface (GUI) 图形用户界面

    通常情况下,Shell 主要是指 CLI 程序,常见的 CLI shell 有 Bash、Zsh、Fish、PowerShell、Cmd 等。

    为什么会有 CLI Shell ?

    早期的计算机主机存放在专门的机房中,用户想要操作计算机需要通过终端(Terminal)连接,终端就是计算机的输入输出设备。最早的终端无法显示图像,只能通过屏幕跟键盘展示、输入字符,所以最早的 Shell 软件就以 CLI 的形式问世了。

    随着技术的进步发展,只能用来传输文本的终端设备几乎都被显示器替代了,GUI 取代了 CLI 的在人机交互方面的主要地位。

    CLI 优势

    CLI 最大的优势在于可编程,CLI shell 通常也是一个脚本语言解释器,可以通过编写 Shell 脚本来自动化完成一些复杂、重复的操作,这是 GUI 难以比拟的。

    在 GUI 成为主要人机交互界面的今天,终端模拟器(Terminal Emulator)是我们操作 CLI 的必备工具。一个优秀的终端模拟器通常会提供下面几个功能:

    • 多 Tab 切换
    • 窗口分屏
    • 搜索命令行输出(ScrollBack)
    • 自动识别链接

    这些功能也是提高 CLI 使用效率的必备功能,如果你的终端模拟器不支持这些功能,我推荐你使用下面的这些软件:

    • Windows
      • Windows Terminal
      • ConEmu
    • macOS
      • iTerm2
    • Linux
      • Konsole
      • Kitty

    我非常建议你设置 Tab 切换跟窗口分屏快捷键,这些操作可以极大地提升你使用 CLI 时的工作效率。

    设置一个用户友好的 CLI Shell

    Fish Shell 是一个智能的、用户友好的 Shell 程序。相比 Bash,它有如下优点:

    • 自动补全
    • 简单易学的脚本语法
    • 基于 Man 的智能提示与补全
    • 开箱即用,只要安装上就能够获得生产力的提升

    安装 Fish

    不建议将默认 Shell 修改为 Fish,因为 Fish 不遵循 POSIX Shell 规范,不一定跟其他的程序兼容,更好的做法是将兼容 POSIX 的 Shell 作为默认 Shell,然后在终端模拟器中将默认启动的 Shell 设置为 Fish。

    CLI 好伙伴

    • man CLI 程序说明书
    • bat 文本文件查看工具
    • tldr man 手册太长不看,就看 TLDR
    • fd 文件搜索工具
    • fuck 智能的命令行纠错工具
    • exa 更加友好的 ls 替代品

    进一步提升 Fish 的易用性

    • Fisher Fish 插件管理器
    • fzf fzf 命令行模糊搜索程序
    • z 智能的目录跳转工具
    • starship 高性能的 Shell 提示符

    fzf

    通过 fisher 安装 PatrickF1/fzf.fish

    • ctrl+r 搜索历史
    • ctrl+alt+f 从当前目录开始搜索文件,按 Tab 可以多选

    更多快捷键可以查看 GitHub Readme

    常用快捷键

    function fish_user_key_bindings
        # 使用 Ctrl + F 补全整行
        bind -M insert \cf accept-autosuggestion
        bind \cf accept-autosuggestion
    end
    • alt+. 将上个命令的最后一部分填到当前命令行末尾
    • alt+s 用 sudo 执行上条命令

    更多快捷键请查看文档

    私人订制,打造合适自己的 CLI 工具

    alias 给常用的命令设置别名,方便快速输入。

    funced/funcsave/function function 比 alias 更加完善,可以用来封装更加复杂的操作。

  • 什么时候应该避免 barrel import

    TL;DR
    不要从导出当前模块的 barrel file 导入其他模块。

    什么是 Barrel file

    用来重新导出当前目录下的子模块,缩短外部 import 路径,通常命名为 index.ts

    DO NOT

    上图存在循环依赖,如果 pluign-c 尝试在顶层访问 PluginB,有可能会得到 undefined,这取决于 plugin-b、plugin-c 在 barrel 中的顺序

    DO

  • 给 cypress 添加 todo

    jest 提供的 test.todo 功能在实践 TDD 的时候非常实用,但是 cypress 却没有提供类似的功能。经过简单的搜索,找到了一个 workaround。

    const test = it;
    test.todo = it.skip;
    global.test = it;

    接着就可以在测试代码中愉快地使用 test.todo 了。

  • 「推敲代码」如何为回调函数设计 API

    TLDR

    在设计回调函数形式的扩展点时,扩展点的默认行为也要提供给调用方,方便基于默认行为进行扩展。达到这一目的的模式有:

    1. 把回调函数提取成单独的类,把默认行为作为基类方法提供给调用方
    2. 把默认行为作为回调函数的参数提供给调用方

    推敲过程

    Alice:Hi,Bob,我有一个重构代码的需求,能帮我看看吗?

    Bob:没问题,Show me your code 。

    Alice:之前的代码是这样的

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params) {
        // do something
        this.fooBehavior();
        // finally
        this.update();
      }
    }

    现在,我需要允许调用方修改 changeState 要执行的行为

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, behaviorType?: string) {
        // do something
        if (behaviorType === 'A') {
          // do behavior A
        } else if (behaviorType === 'B') {
          // do behavior B
        } else {
          this.fooBehavior();
        }
        // finally
        this.update();
      }
    }

    Bob:根据以往的经验,这里的 if-else 很可能会变成叠 buff 的屎山,建议把 behaviorType 换成一个回调函数,让调用方决定这里应该干些啥。

    Alice:改好了,你再看看?

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, onChangeState?: () => void) {
        // do something
        if (onChangeState) {
          this.onChangeState()
        } else {
          this.fooBehavior();
        }
        // finally
        this.update();
      }
    }

    Bob:这里还有些问题,调用方只能覆盖默认行为,并不能在默认行为的基础之上做扩展。

    Alice:调用方也可以自己手动调用 fooBehavior 啊,这也是个公共 API 。

    Bob:但是调用方必须得翻看 changeState 的具体实现才知道「fooBehavioronChangeState 的默认实现,这显然是不合理的。

    Alice:在使用这个类时翻看源码很正常,因为这是一个非常高级晦涩的 API,不翻看源码根本不清楚要用它来干什么。

    Bob:OK,就算调用方在拓展这个地方的时候来查看了 onChangeState 的默认实现,然后把这里的代码复制过去来基于默认行为做扩展。但是,随着版本更新迭代,MyClass 中的默认实现可能会发生变更,这个时候,要怎么通知这些依赖默认行为的调用方更新呢?

    Alice:好吧,这样看来确实是个问题。

    Bob:我觉得这个时候可以把 onChangeState 提取成一个单独的类,它是这个扩展点的基类。调用方在进行扩展的时候,可以通过基类的方法来调用默认行为。

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, changeStateHook: ChangeStateHook = new ChangeStateHook()) {
        // do something
        changeStateHook.after(this);
        // finally
        this.update();
      }
    }
    class ChangeStateHook {
      after(instance: MyClass) {
        instance.fooBehabior();
      }
    }
    class ConsumerChangeStateHook extends ChangeStateHook {
      after(instance: MyClass) {
        if(/* some case */) {
          super.after();
        } else {
          // do something else
        }
      }
    }

    Alice:这样倒是可以满足你在上面提到的需求,但是代码看起来也太阵仗了。为了自定义这个回调函数,还得专门声明一个类!我觉得用个字段来存储默认行为,然后在注释上标明就足够了。

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public defaultOnChangeState() {
        this.fooBehavior();
      }
      /**
       * 默认 onChangeState 是 defaultOnChangeState
       */
      public changeState(params, onChangeState?: () => void) {
        // do something
        if (onChangeState) {
          this.onChangeState();
        } else {
          this.defaultOnChangeState();
        }
        // finally
        this.update();
      }
    }

    Bob:这样做确实轻量很多,但并不是一个好的「模式」。如果后面这种拥有默认行为的回调函数越来越多, MyClass 上就会有很多 defaultOnXxxx ,看起来就很糟心。不如把默认行为作为回调函数的参数提供给调用方。

    class MyClass {
      public update() {}
      public fooBehavior() {}
      public changeState(params, onChangeState?: (defaultOnChangeState: () => void) => void) {
        // do something
        const defaultOnChangeState = () => {
          this.fooBehavior();
        }
        if (onChangeState) {
          this.onChangeState(defaultOnChangeState);
        } else {
          defaultOnChangeState();
        }
        // finally
        this.update();
      }
    }

    Alice:看起来很不错,跟我写的第一版回调函数形式 API 相比没有多几行代码,但是却有更加灵活的扩展空间。

    Bob:好的,最后来总结一下:

    • 回调函数的默认行为需要作为 API 的一部分提供给调用方,这样调用方可以在不查看具体实现的情况下,基于默认行为做扩展。
    • 把回调函数提取成类,通过类的继承,可以将默认行为作为基类的方法提供给调用方。
    • 一个不使用类的更轻量的做法,将默认行为作为回调函数的参数提供给调用方。
Index