Skip to content

fix: add virtual destructor to npu_cmd to prevent heap corruption (affects v0.9.42+) - #580

Open
rmanicardi wants to merge 1 commit into
ROCm:mainfrom
rmanicardi:fix/npu-cmd-virtual-destructor
Open

fix: add virtual destructor to npu_cmd to prevent heap corruption (affects v0.9.42+)#580
rmanicardi wants to merge 1 commit into
ROCm:mainfrom
rmanicardi:fix/npu-cmd-virtual-destructor

Conversation

@rmanicardi

Copy link
Copy Markdown

Summary

  • npu_cmd declares pure virtual methods but has no virtual destructor
  • npu_sequence owns a std::vector<std::unique_ptr<npu_cmd>> of heterogeneous derived objects (e.g. npu_write_cmd, 48 bytes)
  • When the vector is cleared/destroyed, unique_ptr<npu_cmd> calls operator delete(ptr, sizeof(npu_cmd)) = operator delete(ptr, 8) — wrong-sized deallocation of a 48-byte object, corrupting glibc heap metadata → SIGABRT
  • Also adds #include <climits> which is required for UCHAR_MAX used in the op_headers enum (XAIE_IO_CUSTOM_OP_MAX = UCHAR_MAX)

Affected versions

Version Behaviour
≤ 0.9.40 Latent — npu_write_cmd was ~8 bytes, so wrong-sized delete freed the correct amount by accident
0.9.42, 0.9.43 Fatalnpu_write_cmd is now 48 bytes; wrong-sized delete corrupts allocator chunk metadata → SIGABRT

Root cause

// npu_cmd has pure virtual methods but no virtual destructor:
struct npu_cmd {
    virtual int print_cmd(...) = 0;
    virtual void to_npu(...) = 0;
    virtual void dump_cmd(...) = 0;
    virtual int get_op_lines() = 0;
    // virtual ~npu_cmd() = default;  ← MISSING
};

Deleting through a base pointer without a virtual destructor is undefined behaviour per C++ [expr.delete] §3 when the dynamic type differs from the static type.

Crash — reproduced on v0.9.43 (Fedora 44 / GCC 14 / ASan)

==12869==ERROR: AddressSanitizer: new-delete-type-mismatch on 0x7b3d1bc5bf90 in thread T0:
  object passed to delete has wrong type:
  size of the allocated type:   48 bytes;
  size of the deallocated type: 8 bytes.
    #0  operator delete(void*, unsigned long)          (libasan.so.8)
    #1  npu_sequence::clear_cmds()                     (libqwen2_npu.so)
    #2  qwen3_5vl_npu_sequence::_gen_sequence(...)     (libqwen3_5vl_npu.so)
    #3  qwen3_5vl_npu::Impl::set_context_length(...)   (libqwen3_5vl_npu.so)
    #4  qwen3_5vl_npu::Impl::clear_context()           (libqwen3_5vl_npu.so)
    #5  Qwen3_5VL::load_model(...)                     (flm)
    #6  RestHandler::ensure_model_loaded(...)           (flm)
    #7  RestHandler::RestHandler(...)                   (flm)
    #8  create_lm_server(...)                           (flm)
    #9  main                                            (flm)

0x7b3d1bc5bf90 is located 0 bytes inside of 48-byte region
allocated by thread T0 here:
    #0  operator new(unsigned long)                    (libasan.so.8)
    #1  npu_sequence::rtp_write(...)                   (libllama_npu.so)
    #2  qwen3_5vl_npu_sequence::_gen_sequence(...)     (libqwen3_5vl_npu.so)
    #3  qwen3_5vl_npu::Impl::set_context_length(...)   (libqwen3_5vl_npu.so)

Trigger: flm serve qwen3.5:4b --ctx-len 4096 — process aborts during model loading before serving any request.

Fix

struct npu_cmd {
    virtual ~npu_cmd() = default;   // ← add this line

    virtual int print_cmd(...) = 0;
    virtual void to_npu(...) = 0;
    virtual void dump_cmd(...) = 0;
    virtual int get_op_lines() = 0;
};

Note: all pre-built NPU libraries must be recompiled against this updated header for the fix to take effect in their translation units.

Test plan

  • Build flm with ASan (-fsanitize=address)
  • flm serve qwen3.5:4b --ctx-len 4096 — confirm no new-delete-type-mismatch at startup
  • Send inference request — confirm no crash during prefill
  • Rebuild all pre-built NPU .so libraries against the updated header

🤖 Generated with Claude Code

Without a virtual destructor, deleting a derived npu_cmd object (e.g.
npu_write_cmd, 48 bytes) through a base npu_cmd pointer invokes the
base-class destructor and calls operator delete with sizeof(npu_cmd)=8,
not sizeof(npu_write_cmd)=48.  This is undefined behaviour per
[expr.delete] §3 and causes heap corruption: SIGABRT during model
loading or on the first inference request.

The bug is latent in all prior versions.  It became fatal in 0.9.42
because npu_write_cmd gained several data fields, growing from ~8 bytes
to 48 bytes.  With the old layout, the wrong-sized delete accidentally
freed the correct number of bytes; at 48 bytes it corrupts the
allocator's chunk metadata.

Fix: add `virtual ~npu_cmd() = default;` so that unique_ptr<npu_cmd>
destruction dispatches through the vtable to the correct derived
destructor and sized-deallocation uses the actual object size.

Also add #include <climits> to npu_cmd.hpp: UCHAR_MAX (used by the
op_headers enum for XAIE_IO_CUSTOM_OP_MAX) is defined there; relying on
it arriving transitively from other headers is fragile.

All pre-built NPU libraries must be recompiled against this updated
header for the fix to take effect in those translation units.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@astrelsky

Copy link
Copy Markdown

This is clearly correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants