コンテンツにスキップ

FFIライブラリ使用状況調査 — `rigor-ffi`設計の基礎(2026-05-25)

ステータス: 調査ノート、設計コミットメントなし

FFIバインディングgemをADR-0 / ADR-1ロバストネス原則が要求するレベルで静的解析可能にする将来のrigor-ffiプラグインの前提条件。

最終目標はこれらのgemを使用するRubyコードの型チェック — つまり、Ethon::Easy#performRbNaCl::SecretBox#encryptZMQ::Context#socket等を呼び出すユーザーコード。中間目標(FFIプラグイン)は、Dynamic[Top]に全面的に頼らずに型情報がFFI境界を越えて流れるための静的基板。

ソースは/tmp/ffi-survey/<lib>/内のシャロークローンで読んだ;クローンはコミットされておらず、サブモジュールとして参照もされていない。引用はクローンのファイルパスを指すので、将来の読者が検証のために再クローンできる。

設計空間を意図的に網羅する5ライブラリを調査:

ライブラリバインディングスタイル目的
ethon純FFIlibcurlバインディング(typhoeusエンジン)
rbnacl純FFI + カスタムDSLラッパーlibsodium / NaCl暗号
ffi-rzmq純FFI (gem境界を越えて — ffi-rzmq-core)ZeroMQバインディング
childprocessFFIなし(Ruby Process / IOのみ)クロスプラットフォームの子プロセス制御
sassc-ruby純FFI + ベンダーCソースビルドlibsassバインディング

childprocessはFFIを使わないgemの意図的な否定ケースとして含まれている——FFIを使うと思われながら実際は使わないgemを検証する。rigor-ffiがFFIバインディングコードの存在を根拠に有効化されるべきであり、「このgemはOSと対話する」という理由ではないことを確認する。

各セクションで同じ10の質問に答えており、ライブラリ間の知見をクロスリードできる。集約された評価(プラグインの設計形状の決定)はクロージングセクションにある。


lib/ethon/下の5ファイル:

  • curl.rb — トップレベルのEthon::Curlモジュール、FFI::Libraryextendし、カタログモジュールをmix-in(curl.rb:9-30)。
  • curls/settings.rb — 5つのcallback宣言 + ffi_libターゲット。
  • curls/functions.rb — すべてのattach_function呼び出し。
  • curls/classes.rbFFI::Struct / FFI::Union定義。
  • libc.rbselect(2)getdtablesizeのための最小libcシム。

attach_function呼び出し合計約32件(libcurl 29件 + libc 2件 + select 1件)。

ffi_lib ['libcurl', 'libcurl.so.4']curls/settings.rb:10)。select向けのプラットフォームシムはWindowsではws2_32、それ以外ではFFI::Library::LIBCを選択(curls/functions.rb:48-52)。env-varやGem::Util.find_library解決なし。

明示的なtypedef呼び出しなし——FFIビルトイン(:size_t:time_t:suseconds_t:long_long:int64)を直接使用。カスタム名はenum名として導入され、typedefとしてではない(§7参照)。

3つの代表的な形状(curls/functions.rb):

# (a) 単純なポインター返し
base.attach_function :easy_init, :curl_easy_init, [], :pointer # :15
# (b) enum + varargs
base.attach_function :easy_setopt, :curl_easy_setopt,
[:pointer, :easy_option, :varargs], :easy_code # :18
# (c) .ptr構文でのstruct-to-pointerの返し
base.attach_function :version_info, :curl_version_info, [],
Curl::VersionInfoData.ptr # :42

エラーラッピングはFFI境界ではなくRubyレイヤーにある——例: curl.rb:61global_initが非ゼロを返した場合にraiseする。

5つのcallback typedef(curls/settings.rb:4-8):

callback :callback, [:pointer, :size_t, :size_t, :pointer], :size_t
callback :socket_callback, [:pointer, :int, :poll_action, :pointer, :pointer], :multi_code
callback :timer_callback, [:pointer, :long, :pointer], :multi_code
callback :debug_callback, [:pointer, :debug_info_type, :pointer, :size_t, :pointer], :int
callback :progress_callback, [:pointer, :long_long, :long_long, :long_long, :long_long], :int

RubyのProcはラッピングオブジェクトに保存され、easy_setoptに直接渡される(easy/callbacks.rb:23, :38-44)。FFIがブリッジする。

classes.rb内の4つのキャリア:

  • MsgDataUnion:5-7) — :whatever, :pointer / :code, :easy_code
  • MsgStruct:10-12) — :code, :msg_code, :easy_handle, :pointer, :data, MsgData
  • VersionInfoDataStruct:14-24) — ランタイムバージョンメタデータ。
  • FDSetStruct:27-52) — プラットフォーム条件付きレイアウト(Windows対POSIX)。
  • TimevalStruct:55-62) — 同様。

ManagedStructなし、BitStructなし。FDSetが唯一のプラットフォーム分岐レイアウト。

FFI::Library#enumの多用(constants.rb:17-78):

EasyCode = enum(:easy_code, easy_codes)
EasyOption = enum(:easy_option, easy_options(:enum).to_a.flatten)
PollAction = enum(:poll_action, [:none, :in, :out, :inout, :remove])
SocketReadiness = bitmask(:socket_readiness, [:in, :out, :err])

カタログはEthon::Curls::CodesOptionsInfosから来る——シンボル配列を返す単純なRubyモジュール。

8. Pointer / MemoryPointer / AutoPointerラッピング

Section titled “8. Pointer / MemoryPointer / AutoPointerラッピング”

FFI::AutoPointerが主要なライフサイクルパターンeasy/operations.rb:14multi/operations.rb:17):

@handle ||= FFI::AutoPointer.new(Curl.easy_init, Curl.method(:easy_cleanup))

マルチループ中のin/outパラメータ向けの明示的なMemoryPointer.new(:long) / Curl::Timeval.new。ProcはGCルートを保つために@procsハッシュに格納される(easy/options.rb:42-45)。

9. ユーザー向けRuby API → FFIマッピング

Section titled “9. ユーザー向けRuby API → FFIマッピング”

Easy#url=が標準的なブリッジ(easy/options.rb:10-13):

def url=(value)
@url = value
Curl.set_option(:url, value, handle)
end

Curl.set_optioncurls/options.rb:13-109)はオプションカタログの:typeフィールドでディスパッチし、varargs経由でeasy_setopt(handle, const, va_type, value)を呼ぶ。各オプションの:type:string / :long / :callback / :string_as_pointer)がランタイム型識別子。

回収可能: FFIシグネチャ(ロード時)、structレイアウト、5つのcallback typedef、enumシンボル→intマッピング(カタログモジュールから)。

オプションディスパッチャーをモデル化しないと回収不可:

  • easy_setopt内のvarargs — 第3引数のFFI型はカタログを経由してoption enum値によって決まる。ナイーブなウォーカーは[:pointer, :easy_option, :varargs]を見てお手上げになる。
  • オプションカタログ上のdefine_methodeasy/options.rb:32-47) — すべてのセッター(url=ssl_verifypeer=writefunction)はロード時にCurl.easy_options(nil)を反復して生成される。セッターはASTに存在しない。

rigor-ffiにとって、オプションカタログ + 生成セッターのモデル化が標準的なFFIバインディング認識を超える主要な精度レバー。


rbnacl — libsodium / NaClバインディング

Section titled “rbnacl — libsodium / NaClバインディング”

プリミティブごとの分割——暗号構成ごとに1モジュール(SecretBoxes::XSalsa20Poly1305Signatures::Ed25519Hash::{SHA256, SHA512, Blake2b}HMAC::*AEAD::*PasswordHash::Argon2OneTimeAuths::Poly1305など)。各モジュールがSodiumextendしてFFIマシナリーを継承(lib/rbnacl/sodium.rb:6-66)。

attach相当呼び出し合計約52件、大半がリテラルのattach_functionではなくカスタムDSLラッパーのsodium_function経由。

ffi_lib ["sodium", "libsodium.so.18", "libsodium.so.23", "libsodium.so.26"]

ABIバージョンにまたがる静的フォールバックリスト(init.rb:8と、遅延extendされるモジュールのためにsodium.rb:11にも)。sodium/version.rb:22-25でlibsodium < 0.4.3の場合にraise;feature gate(例: lib/rbnacl.rb:81のArgon2)が検出されたバージョンに基づいて条件付きでrequireする。

なし。プリミティブFFI型(:pointer:ulong_long:size_t:uint{8,32,64}:string:int)を全体で直接使用。

4. attach_functionパターン — カスタムDSLラッパー

Section titled “4. attach_functionパターン — カスタムDSLラッパー”

静的解析の重大なハザードsodium_functionsodium.rb:47-55)がmodule_eval内の補間ヘレドックでattach_functionをラップする:

def sodium_function(name, function, arguments)
module_eval <<-RUBY, __FILE__, __LINE__ + 1
attach_function #{function.inspect}, #{arguments.inspect}, :int
def self.#{name}(*args)
ret = #{function}(*args)
ret == 0
end
RUBY
end

rigor-ffiにとって重要な3つのプロパティ:

  • 戻り型が暗黙的 — すべてのsodium_functionがFFIレイヤーで:intを返し、Rubyレイヤーでtrue|falseを返す。
  • 引数はヘレドック内のクォートされたシンボルであり、実際のattach_function呼び出しノードではない——attach_functionをgrep-スキャンするウォーカーはそれらを完全に見逃す。
  • 2つの名前 — C関数(:crypto_secretbox_xsalsa20poly1305)とRubyメソッド(:secretbox_xsalsa20poly1305)——がどちらもリテラル引数。

代表的な呼び出しサイト(secret_boxes/xsalsa20poly1305.rb:32-38):

sodium_function :secretbox_xsalsa20poly1305,
:crypto_secretbox_xsalsa20poly1305,
%i[pointer pointer ulong_long pointer pointer]

このパターンがrbnaclの圧倒的に支配的なバインディング形状。伴うDSL sodium_constantsodium.rb:32-45)がKEYBYTES / NONCEBYTES / … 定数に同じ技法を使う。

なし。libsodiumはAPIサーフェスにコールバックを持たない。

4つのストリーミングハッシュ状態struct(HMAC::SHA256::StateHMAC::SHA512::StateHMAC::SHA512256::StateHash::Blake2b::State)。代表例(hmac/sha256.rb:95-106):

class SHA256State < FFI::Struct
layout :state, [:uint32, 8],
:count, :uint64,
:buf, [:uint8, 64]
end

ManagedStructUnionBitStructなし。

なし。

8. Pointer / MemoryPointer / バッファラッピング

Section titled “8. Pointer / MemoryPointer / バッファラッピング”

rbnaclはMemoryPointerを意図的に回避する — バッファはBINARYエンコーディングの単純なRuby String。ユーティリティはUtil.zeros(n)util.rb:22-27):

def zeros(n = 32)
zeros = "\0" * n
zeros.respond_to?(:force_encoding) ? zeros.force_encoding("ASCII-8BIT") : zeros
end

FFIが呼び出し時にString:pointerをオートコーションする。検証はUtil.check_string / Util.check_lengthutil.rb:111-117)を通じる。

9. ユーザー向けRuby API → FFIマッピング

Section titled “9. ユーザー向けRuby API → FFIマッピング”

SecretBox#boxが標準的(secret_boxes/xsalsa20poly1305.rb:49-76):

def box(nonce, message)
Util.check_length(nonce, nonce_bytes, "Nonce")
msg = Util.prepend_zeros(ZEROBYTES, message)
ct = Util.zeros(msg.bytesize)
success = self.class.secretbox_xsalsa20poly1305(ct, msg, msg.bytesize, nonce, @key)
raise CryptoError, "Encryption failed" unless success
Util.remove_zeros(BOXZEROBYTES, ct)
end
alias encrypt box

5ステップのブリッジ: 長さ検証 → ゼロパディング → Stringとして出力確保 → sodium_function生成ラッパーを呼ぶ → パディング除去。ユーザーサーフェスは(String, String) -> String;FFI型はそれを何も伝えない。

回収可能、プラグインがsodium_functionをバインディング定義マクロとして理解する場合:

  • sodium_function(ruby_name, c_name, arg_types)attach_function c_name, arg_types, :int + Booleanを返すself.{ruby_name}メソッドを生成する。
  • sodium_constant(...)attach_function由来の定数を生成する。

AST単独では回収不可(DSL認識なし):

  • 生成されたattach_functiondef self.…(補間されたヘレドック文字列内にあり、呼び出しノードとしてではない)。
  • 条件付きfeatureロード(rbnacl.rb:81)——ランタイムバージョン検出で暗号プリミティブ全体をゲートする。

rbnaclはrigor-ffiプラグインがプラグイン側DSL認識器をサポートすることの最も強い根拠——リテラルのattach_functionが生成するのと同じバインディングファクトを合成するsodium_function対応拡張。


FFIバインディングは別gemに存在するffi-rzmq-coreffi-rzmq.gemspec:24でランタイム依存として宣言されlib/ffi-rzmq.rb:66requireされる。ffi-rzmqコードベース自体(ContextSocketMessagePollerPollItemDeviceUtil、例外クラスにまたがる約1642行)はコアgemからLibZMQ.*上の純Rubyのラッパー

これはFFI解析のクロスgem境界ケース

ffi-rzmqからは不透明——ffi-rzmq-coreで処理される。README(README.rdoc:167-170)はOSパッケージマネージャー / Homebrewでlibzmqをインストールするよう指示する。

:size_tが参照される(socket.rb:531);カスタムtypedefはffi-rzmq-coreにあるだろう。

ffi-rzmqにはない。ラッパーが「負のリターン = エラー、LibZMQ.zmq_errno経由でフェッチ」イディオムでリテラルのLibZMQ.*呼び出しを使う(util.rb:71-78):

rc = LibZMQ.zmq_setsockopt @socket, name, pointer, length
ZMQ::Util.error_check 'zmq_setsockopt', rc

LibC::Freezmq_msg_init_dataデストラクターとして渡される(message.rb:134)。ファイナライザーはLibZMQ.zmq_close / zmq_ctx_termを呼ぶクロージャでObjectSpace.define_finalizerを使う(context.rb:132socket.rb:580-583)。

LibZMQ::PollItem(ffi-rzmq-coreで定義、poll_item.rb:11poll_items.rb:13で参照)。zmq_msg_tはLayoutなしにFFI::MemoryPointer.new(Message.msg_size, 1, false)message.rb:99)として不透明にラップされる。

なし。ソケットタイプ(ZMQ::REQ = 3ZMQ::REP = 4、…)とオプションキー(ZMQ::RCVMOREZMQ::LINGER、…)は単純なRuby定数。オプションディスパッチはロード時に1度構築された3つの配列——IntegerSocketOptionsLongLongSocketOptionsStringSocketOptionssocket.rb:569-571)——を通じる。

8. Pointer / MemoryPointer / バッファラッピング

Section titled “8. Pointer / MemoryPointer / バッファラッピング”

ファイナライザーベースのライフサイクル: すべてのcontext / socketがforkの後のダブルクローズを避けるためにProcess.pid == pidでゲートされた対応するzmq_*クリーンアップ関数を呼ぶdefine_finalizerを登録する。オプションバッファはLibC.malloc / freesocket.rb:129, 145)またはキャッシュされたFFI::MemoryPointersocket.rb:537-547)のいずれか。

9. ユーザー向けRuby API → FFIマッピング

Section titled “9. ユーザー向けRuby API → FFIマッピング”

ZMQ::Context#socket(type)context.rb:109-118)がSocket.new(@context, type)socket.rb:65-88)に委譲し、LibZMQ.zmq_socketを呼んで結果のポインタを保存する。ユーザー向けの型識別子はSymbolではなく整数定数type:REQ短縮形なし)。

Socket#send_stringsocket.rb:244-247)がStringをMessageにラップし、zmq_msg_tを確保してバイトをコピーする。

難しい問題: ffi-rzmqのシグネチャはgem境界を越えた場所にある。ユーザーのプロジェクトのみをスキャンするrigor-ffiプラグインは、以下なしにLibZMQ.zmq_socketのarity / 戻り型を見られない:

  • ffi-rzmq-coreのソースにウォークインする(ADR-10オプトインの依存関係ソース推論を参照)、または
  • LibZMQ向けのバンドルRBSを出荷する(ADR-25プラグイン提供RBSを参照)。

追加の精度ブロッカー:

  • Socket.newsocket.rb:32, 68)内の:receiver_classポリモーフィズム — recvmsgがユーザーの選択したクラス(デフォルトZMQ::Message)を返す。
  • ZeroMQ 3.2.xと4.xの間のバージョン条件付きFFI——ffi-rzmq-core内で処理され、ラッパーレイヤーでは不可視。
  • socket.rb:569-571の3つの配列を通じた動的オプションディスパッチ。

自然なrigor-ffi形状: ffi-rzmq-coreのattach_function呼び出しを依存関係としてスキャンし、次にそれらのシグネチャ上でffi-rzmqのラッパークラスメソッドを型付けする。


childprocess — 意図的な否定ケース

Section titled “childprocess — 意図的な否定ケース”

require 'ffi'なし、childprocess.gemspecにFFI依存なし、attach_function / callback / FFI::Struct呼び出しゼロ。

アーキテクチャ: childprocess.rb:17-25のプラットフォームディスパッチャーがUnix::ProcessまたはWindows::Processを選択し、どちらもRubyビルトインの::Process.spawn / ::Process.waitpid2 / ::Process.killに委譲する。WindowsのSIGKILLフォールバックはtaskkill /F /T /PIDへのシェルアウト(process_spawn_process.rb:117-123)。Pipeは::IO.pipeを使用。

childprocessはプラグインが検出されたFFIバインディングコードrequire 'ffi' + extend FFI::Library + 少なくとも1つのattach_functionまたは同等のもの)を根拠に有効化されるべきであり、「このgemはローレベルに見える」という理由ではないことを検証する。RubyのProcess / IOスタンダードライブラリを使うgemはコアRubyの問題であり、FFIプラグインの問題ではない。


sassc-ruby — FFI経由のベンダーlibsass

Section titled “sassc-ruby — FFI経由のベンダーlibsass”

Cエクステンションを宣伝するgemであるにもかかわらず純FFI。sassc.gemspec:27spec.extensions = ["ext/extconf.rb"]を宣言するが、エクステンションはrb_define_method Cエクステンションではない——extconf.rb:59-64)がベンダードのlibsassサブモジュールをシンプルな共有ライブラリ(libsass.{bundle,so})にコンパイルし、その後ランタイムでFFI経由でロードされるlib/sassc/native.rb:9-14):

dl_ext = RbConfig::MAKEFILE_CONFIG['DLEXT']
begin
ffi_lib File.expand_path("libsass.#{dl_ext}", __dir__)
rescue LoadError
ffi_lib File.expand_path("libsass.#{dl_ext}", "#{__dir__}/../../ext")
end

lib/sassc/native/下の3つのサブモジュール: native_context_api.rbnative_functions_api.rbsass2scss_api.rb

ext/libsass下のベンダードlibsass(サブモジュール)。gem installビルド時にビルド;上記パス経由でランタイムにロード。システムlibsassのフォールバックなし。

調査対象のgemの中で最も広範なtypedef使用(lib/sassc/native.rb:18-29)。10個の不透明ポインタエイリアス: :sass_options_ptr:sass_context_ptr:sass_file_context_ptr:sass_data_context_ptr:sass_value_ptr:sass_import_list_ptr:sass_import_ptr:sass_importer:sass_c_function_list_ptr:sass_c_function_callback_ptr

すべて:pointerに解決するが名前による区別を提供する——調査コーパスで最も優れた静的解析フック。

標準的、加えてプレフィックスストリップ規約 —— attach_functionがオーバーライドされ(lib/sassc/native.rb:39-47)、sass_make_optionsNative.make_optionsとして登録する。例(native_context_api.rb:9, 24, 44, 106):

attach_function :sass_make_options, [], :sass_options_ptr
attach_function :sass_compile_file_context, [:sass_file_context_ptr], :int
attach_function :sass_delete_data_context, [:sass_data_context_ptr], :void
attach_function :sass_option_set_output_style,
[:sass_options_ptr, SassOutputStyle], :void

2つのtypedef(lib/sassc/native.rb:31-32):

callback :sass_c_function, [:pointer, :pointer], :pointer
callback :sass_c_import_function, [:pointer, :pointer, :pointer], :pointer

ユーザー提供RubyのProcはFFI::Function.newでラップされ(functions_handler.rb:23-26import_handler.rb:26-33)、ラッパーを生かしておくために@callbacksに格納される。

Sass値のタグ付きユニオンが最大のレイアウト(lib/sassc/native/sass_value.rb:84-95):

class SassValue
layout :unknown, SassUnknown,
:boolean, SassBoolean,
:number, SassNumber,
:color, SassColor,
:string, SassString,
:list, SassList,
:map, SassMap,
:null, SassNull,
:error, SassError,
:warning, SassWarning
end

各バリアントが独自のSassTag識別子を持つ(:33-37等)。

3つのenum(sass_output_style.rb:5-10sass_value.rb:7-22):

SassOutputStyle = enum(:sass_style_nested, :sass_style_expanded,
:sass_style_compact, :sass_style_compressed)
SassTag = enum(:sass_boolean, :sass_number, :sass_color, :sass_string,
:sass_list, :sass_map, :sass_null, :sass_error, :sass_warning)
SassSeparator = enum(:sass_comma, :sass_space)

attach_functionの引数リストで直接使用されるため、FFIが呼び出しサイトでRubyのシンボルを自動変換する。

8. Pointer / MemoryPointer / ライフサイクルラッピング

Section titled “8. Pointer / MemoryPointer / ライフサイクルラッピング”

2つの注目すべきパターン:

  • 文字列入力 → FFIオーナーシップ転送lib/sassc/native.rb:54-58):

    def self.native_string(string)
    m = FFI::MemoryPointer.from_string(string)
    m.autorelease = false
    m
    end

    autorelease = falseはバッファをlibsassに渡し、libsassがそれを解放する。

  • ensureブロックライフサイクルlib/sassc/engine.rb:63) — Engine#renderのrescue/ensureテールでNative.delete_data_context(data_context)

AutoPointerなし。

9. ユーザー向けRuby API → FFIマッピング

Section titled “9. ユーザー向けRuby API → FFIマッピング”

SassC::Engine#renderlib/sassc/engine.rb:22-64)はストレート8ステップのFFIパイプライン: make_data_context → contextにアンラップ → optionsにアンラップ → 約10個のoption_set_*セッターを呼ぶ → コールバックを登録 → compile_data_context → 出力文字列を抽出 → フリー。メタプログラミングなし——すべてのステップがリテラルのNative.something呼び出し。

調査の中でrigor-ffiの観点から最も行儀の良いライブラリ:

  • すべてのattach_function呼び出しがリテラルの呼び出しノード。
  • typedef済み不透明ポインタがすべてのCリソースに別個のnominal型を与える;プラグインがsass_data_context_ptrNominal[SassC::Native::SassDataContextPtr]として運び、混合を拒否できる。
  • enumが静的に列挙されている。
  • Structレイアウトが静的に宣言されている。

残るギャップはFFI固有ではない:

  • FFI::Function.newコールバック(functions_handler.rb:23-26)は内部のRuby呼び出しに通常のフロー解析を必要とする匿名Proc。
  • .rbsスタブファイルなし——FFI宣言が唯一の契約。

パターンライブラリ静的解析可能性
リテラルのattach_function呼び出しethon、sassc-ruby
attach_functionをラップするカスタムDSL(sodium_function等)rbnaclDSL認識なしでは低
別gem内のバインディング(ffi-rzmq-coreffi-rzmq依存関係ソース推論なしでは低
FFIなし(Ruby Process / IOを使用)childprocessN/A

プラグインが理解しなければならない繰り返すFFIプリミティブ

Section titled “プラグインが理解しなければならない繰り返すFFIプリミティブ”
  • extend FFI::Library + ffi_lib
  • attach_function name, c_name, [arg_types], return_type — ストレートおよび末尾のオプションHash付き(:blocking:enums
  • callback name, [arg_types], return_type
  • typedef existing_type, alias_name — 主として名前付き不透明ポインタエイリアス(sassc-rubyパターン)として
  • enum :name, [:a, :b, …]bitmask(ethon、sassc-ruby)
  • FFI::StructFFI::UnionFFI::ManagedStructFFI::AutoPointer
  • FFI::MemoryPointer.{new, from_string}MemoryPointer#put_bytes / read_string
  • FFI::Pointer.{null?, address}FFI::Pointer::NULL
  • FFI::Function.new(return, [args]) { |…| … } — 匿名コールバック
  • 呼び出し境界でのString ↔ :pointer / :stringオートコーション

静的推論のクロスカッティングハザード

Section titled “静的推論のクロスカッティングハザード”
  1. attach_function周りのDSLラッパー(rbnaclのsodium_function、プレフィックスストリップ用のsassc-rubyのオーバーライドされたattach_function)。プラグインはリテラルのattach_functionが記録するバインディングファクトを再生するDSL認識器を必要とする。これはsodium_function向けのADR-16 Tier C(ヘレドックテンプレート展開)とプレフィックスストリップオーバーライド向けのTier B(トレイトインライニング)にきれいにマップする。

  2. クロスgemバインディング定義(ffi-rzmq → ffi-rzmq-core)。どちらもロードマップにすでにある2つの妥当な答え:

    • プラグイン提供RBSADR-25)——rigor-ffi-rzmq内に手でキュレートされたLibZMQスタブを出荷。
    • 依存関係ソース推論ADR-10)——ffi-rzmq-coreattach_function呼び出しのオプトインウォーク。

    ADR-25が低FPパス;ADR-10が低メンテナンスパス。

  3. enumによってディスパッチされるvarargs(ethonのeasy_setopt)。va_typeは外部カタログを通じて2番目の引数のenum値によって決まる。オプションディスパッチャーをモデル化しないと3番目の引数はDynamic[Top]として型付けされる;カタログがあるとオプションごとの小さなユニオンとして型付けされる。ライブラリごとの特別扱いが多すぎてrigor-ffiコアに属せない——ライブラリごとのプラグイン(rigor-ethonrigor-rbnacl)の上位に属する。

  4. FFIメタデータからの動的メソッド生成easy/options.rb:32-47のoption catalogueへのethonのdefine_method)。生成されたセッター / ゲッターはASTに存在しない。これはADR-16 Tier B / CがDevise / dry-structのために解くのと同じ問題。

  5. nominalサブタイピングとしてのtypedef済み不透明ポインタ。これを多用しているのはsassc-rubyだけだが、最もクリーンなフック——typedef :sass_data_context_ptrNominal[SassC::Native::SassDataContextPtr]になり、ベアの:pointerと暗黙的に統一されるべきではない。これはサーベイの中でロバストネス原則との最も強い整合: 戻り型は厳密(型付きエイリアス)、入力はエイリアスかベースの:pointerのどちらも受け付ける。

  6. 匿名コールバックProcの返し値の型FFI::Function.new(rt, [args]) { … })。ブロック本体は通常の推論エンジンを必要とし、callback typedefがブロックのパラメータ型をバインドする。実装面では最も近いアナローグはADR-28のバインダーティアメカニズム(パラメータ置換のため)。

  7. リソースライフサイクルAutoPointer.new(ctor, dtor_method)ObjectSpace.define_finalizerdelete_*関数を呼ぶensureブロック。これらはそれ自体ではの問題ではない;プラグインがuse-after-free / double-free診断ファミリーを持つ場合にのみ重要になる。初期rigor-ffiのスコープ外。

rigor-ffiの提案形状(初稿、設計コミットメントではない)

Section titled “rigor-ffiの提案形状(初稿、設計コミットメントではない)”

コアプラグインが出荷すべきもの(ethon + sassc-ruby + バニラFFI gemの80%をカバー):

  • extend FFI::Libraryを認識してホストモジュールをバインディングレジストリとして扱う。
  • attach_functioncallbacktypedefenumbitmaskFFI::Struct.layoutFFI::Union.layout呼び出しをウォークしてファクト(メソッドシグネチャ、コールバック型、enumシンボルセット、structフィールドオフセットと型)をエンジンに貢献する。
  • よく知られたFFIキャリア——FFI::PointerFFI::MemoryPointerFFI::BufferFFI::AutoPointerFFI::FunctionFFI::Struct / Unionサブクラス——を正確なメソッドシグネチャで型付けする(read_string : (?Integer) -> Stringput_bytes : (Integer, String, ?Integer, ?Integer) -> self等)。
  • LibZMQ.zmq_send(socket, "hello", 5, 0)がエラーを出さないよう、呼び出し境界でのString ↔ :string / :pointerコーションをモデル化する。
  • typedef済みポインタエイリアスを別個のnominalsとして扱い、入力のみでベースの:pointerを部分型として受け付ける(ロバストネス)。

ライブラリごとのサブプラグインrigor-rbnaclrigor-ethonrigor-ffi-rzmqrigor-sassc)に属するもの:

  • DSL認識器(sodium_function、sassc-rubyのプレフィックスストリップオーバーライド)。
  • バンドルされたLibZMQ形状のRBS(ADR-25)。
  • オプションカタログ → セッターメソッド生成(ethon)。
  • 高レベルAPI精緻化(SecretBox#encrypt: (String, String) -> StringEasy#perform: () -> Integer)。

初期プラグインで意図的にスコープ外のもの:

  • FFIハンドルのuse-after-free / double-free / リーク診断。
  • 具体的なstructフィールド境界(FDSet / SHA256Stateの配列レイアウトは型付けには十分だが境界チェックには不十分)。
  • Cサイドのコンパイル正確性(sassc-rubyのext/libsassビルド)。

childprocessからの否定ケースの教訓

Section titled “childprocessからの否定ケースの教訓”
  • FFIバインディングコードを根拠に有効化し、「ネイティブリソースを使用する」ことを根拠にしない。
  • ::Process.spawn / ::IO.pipe / ::IO.popenで偽陽性を出さない——それらはコアRubyであり、stdlib RBSで処理される(またはされない)。

設計ADRへのオープンクエスチョン

Section titled “設計ADRへのオープンクエスチョン”
  • DSL認識器形状: rigor-ffiはプラガブルな認識器テーブルを持つ単一プラグインか、それともsodium_functionrigor-ffi提供の貢献APIを消費するrigor-rbnaclが出荷するか?(ADR-13のTypeNodeリゾルバチェーン議論の反響。)
  • クロスgemシグネチャ取得: ffi-rzmq-coreケースでADR-25(プラグイン提供RBS)に頼るか、ADR-10(依存関係ソース推論)を待つか? 2つは異なるメンテナンスコスト / 精度のトレードオフを持つ。
  • typedef済み不透明ポインタはデフォルトでnominalサブタイピングにすべきか、それともtypedef呼び出しごとのオプトインか(例: typedef :pointer, :sass_data_context_ptr, nominal: true)? 純「常にnominal」はドキュメントのみの目的でtypedefを使うgemを壊すリスクがある。
  • プラグインはFFI::Function.newとブロックをどの程度積極的にモデル化すべきか? 匿名Proc推論はエンジン全体の作業であり、FFI固有ではない。

補遺 — tenderloveのffxエコシステム(2026-05-25、同日)

Section titled “補遺 — tenderloveのffxエコシステム(2026-05-25、同日)”

rigor-ffiffi gemの代わりに(またはそれに加えて)ffxをターゲットにするプロジェクト向けに別のコードパスを必要とするかどうかを明確化するためのAaron Pattersonの最近のFFI置換作業のフォローアップサーベイ。

読んだソース:

ffxはブログタイトルが1年の進化後に示唆するようなランタイムJITではないffx@HEADで実際に出荷されsqliteffxで実証されている形状は:

  1. ビルド時extconf.rb内のFFX.create_makefile(name, src.rb, headers: [...])src.rbをロードし、FFI::Libraryに対して記録されたすべてのattach_function宣言を内省し、本物のCエクステンション$srcdiremitする。mkmfがそれを.so / .bundleにビルドする。

  2. ランタイム — ロードされるのはコンパイルされたネイティブエクステンションであり、FFIマーシャリングではない。すべてのattach_functionバインドメソッドはrb_define_singleton_method経由で公開される本物のC関数。

  3. JITヒント — 各生成C関数は2つのパートで出荷される: _impl関数(マーシャリング本体)と、jmp _…_implに続くバイナリメタデータブロブ[FFI0マジック | param_count | type_bytes | function_name]のインライン__asm__を含むネイキッドトランポリン。ZJITはオフセット4のブロブを読んで特化した直接呼び出しコードを生成する。

ブログの「tiny JITs」フレームはプロトタイプパスの一部だったfisk / aarch64コードジェンライブラリにマップする;現在の出荷形状は生成されたCのインライン__asm__を使い、アセンブリはmkmf / Cコンパイラに任せる。fisk / aarch64は独立したJITライブラリとして有用なまま、ffxのランタイムパスにはない

報告されたパフォーマンス(コンテキスト用、rigorにとって重要ではない)

Section titled “報告されたパフォーマンス(コンテキスト用、rigorにとって重要ではない)”
キャリア(strlenラッパー)ops/sec
純ffi gem約15 M
手書きCエクステンション約45 M
ffx + ZJIT約54 M

(JP要約の数字;ブログの初期〜32.5 M FJIT対〜29.8 M Cエクステンション(異なるベンチ)と一致。)「RubyのFFIがCエクステンションより速い」という実用的な主張はRubyKaigi 2026キーノートスロットを獲得するほど実証されている。

ffxサーフェス解析(同じ10の質問に対して)

Section titled “ffxサーフェス解析(同じ10の質問に対して)”

ユーザー向けDSLはクラシックFFIとビット同一。ffxがmodule FFX::Libraryを出荷してFFI::Libraryにエイリアスするので既存のバインディングファイルが変更なしにコンパイルされる(ffx.rb:244-246)。extend FFI::Libraryffi_lib "sqlite3"attach_function :name, [args], ret — すべて同じ構文形式。

サーフェスレベルでffi gemと異なるもの:

構文クラシックffiffx
attach_function(プリミティブ)
ffi_lib
typedef✗ 非対応
callback✗ 非対応(sqliteffx README:46-47が確認)
enum / bitmask✗ 非対応
FFI::Struct / Union / ManagedStruct✗ 非対応
可変長引数(:varargs✗ 非対応
カスタム型コンバーター✗ 非対応
プリミティブ型シンボル数十25 — void, int, long, string, uint, size_t, double, float, pointer, bool, char, uchar, short, ushort, int{8,16,32,64}, uint{8,16,32,64}, long_long, ulong_long, ulongffx.rb:12-38

ffxはFFIの厳密なサブセット。これはrigor-ffiにとって最も重要な発見: クラシックFFIを処理するプラグインはffxターゲットコードを自動的に処理する——より少ない認識器サーフェスが発火し、より多くはない。

集中した単一ファイルのバインディング宣言(ext/sqliteffx/sqliteffx.rb、44行)。2つのパターンがプラグイン設計にとって重要:

(a) ffi.rbスタブリダイレクトトリック。sqliteffxは空のext/sqliteffx/ffi.rbをスタブとして出荷する。sqliteffx.rbがextconf時にrequire "ffi"を実行するとき、インストール済みのffi gemよりext/sqliteffx/がRubyロードパスの先にあれば、スタブが勝ってffxのmixinにextend FFI::Libraryが解決する。ランタイムではFFI gemは何もロードされない——コンパイルされたCエクステンションがロードされる。

rigor-ffiにとって、これはextend FFI::Libraryを宣言するプロジェクトファイルがffiまたはffxのどちらかをターゲットにしている可能性があることを意味し、gemビルドプロセスがどちらを使うかをASTだけでは判断できない。プラグインは(a)共通サブセットを無条件にカバーし、(b)プロジェクトが実際にffi gemに依存している場合にのみFFI専用機能認識器(callback、struct、enum、typedef)を発火させなければならない。

(b)生のRuby Integerとしての不透明ポインタ。sqliteffxはSQLiteハンドルをFFI::Pointerでラップしない。8バイトスロットをmallocで確保し、スロットをアウトパラメータとしてsqlite3_openを呼び出し、次にFiddle::Pointer.new(@slot)[0, 8].unpack1("Q<")経由でスロットを読み戻す——単純なRuby Integerが得られる(lib/sqliteffx.rb:41)。そのIntegerを:pointer引数としてSqliteffx.sqlite3_close(@handle)に返す。

ffxの:pointerのマーシャリングは(void *)NUM2ULL(value) — 任意のRuby Integerを受け付ける。クラシックFFIはFFI::Pointerを受け付けて特定の型を自動変換するが、ベアのIntegerは受け付けない

これはrigor-ffiの実際の精度上の問題: :pointerパラメータ型の受け付けられる入力セットはffxではクラシックFFIより広い。ロバストネス原則の答えは、:pointerパラメータの入力FFI::Pointer | Integer | nilに全面的に広げ、非対応型でのffi-gemランタイムエラーはrigor-ffiではなくgem自体が診断するに任せること。戻り型は厳密のまま——クラシックFFIはFFI::Pointerを返し、ffxはIntegerを返す;プラグインはプロジェクトのターゲットに従う。

fisk / aarch64 / JITBuffer — スコープ外の判定

Section titled “fisk / aarch64 / JITBuffer — スコープ外の判定”

どちらのgemもFFIバインディングサーフェス自体を持たない純Rubyの命令エンコーダーDSL(attach_functionなし、extend FFI::Libraryなし、FFI依存なし——実行可能ページをmmapするためにStdlibのFiddleを使用)。ffxを消費するエンドユーザーコードはFisk::*またはAArch64::*シンボルを参照しない。ffx自体ですらそれらに依存しない——インライン__asm__トランポリンはコードジェンgemを完全に回避する。

推奨: rigor-ffiはfiskとaarch64を無視する。それらはJITを書く人が使うコードジェンバックエンドであり、FFIバインディングを書く人が使うものではない。将来のrigor-jitプラグイン(もし要望があれば)がそれらを拾い上げるかもしれない;それは別のサーフェス領域。

メインノートのクロージングセクションの計画は3つの調整とともに維持される:

(1)コアプラグインはすでにffxを処理する。ffxは「コアプラグイン」の箇条書きリストにすでにあるFFIサブセット(リテラルのattach_functionffi_lib、プリミティブ型シンボル)のみを受け付けるため、ffx固有の認識器は不要。これは実質的に朗報——rigor-ffiの最初の具体的な消費者をffx固有の作業ゼロでsqliteffxにできることを意味する。

(2)新しい診断ファミリーの機会 — ffx.unsupported-feature。プロジェクトのgemspecがffx gemを解決する(またはextconf.rbFFX.create_makefileを使用する)場合、プラグインはffx非互換な宣言を静的に検出して診断できる:

  • callback :foo, [...], :intffx.unsupported-callback
  • class S < FFI::Structffx.unsupported-struct
  • typedef :pointer, :handleffx.unsupported-typedef
  • enum :state, [:open, :closed]ffx.unsupported-enum
  • attach_function :printf, [:string, :varargs], :intffx.unsupported-varargs
  • ffx-25リスト外の任意の型シンボル → ffx.unsupported-type

これらはffxがコンパイルに失敗するケースそのものなので、診断は実際の今日のバグを表面化する——スタイルの好みではない。プロジェクトの設定経由のオプトイン(rigor-ffi設定のtarget: ffx軸)の強力な候補でもある——「ffx gemを使う」は存在ベースであり、デュアルターゲットgemでノイズを表面化すべきでない。

(3)ポインタパラメータ入力セットを拡大:pointerパラメータの推奨型はFFI::Pointer | Integer | nil(ロバストネス: 入力には寛容に)。:pointerを返す関数の戻り型は厳密でターゲット依存のまま: ffi-gemターゲットはFFI::Pointer、ffxターゲットはInteger

(4)長期的、オプション — 二次シグネチャソースとしてのFFI0トランポリンメタデータの解析。プラグインがRubyバインディングファイルが隠れている(ベンダーブロブ、カスタムラッパー)がインストール済みの.soが存在するgemに遭遇したとき、FFI0マジック + type-byteブロブを解析することで(name, arity, param_types, return_type)をバイナリから回収できる。これはトランポリンメタデータの「静的解析フレンドリー性」が取れる最も強い形式。初期プラグインのスコープ外(プラットフォーム対応のELF / Mach-O / PE解析が必要)だが将来の方向として記録に値する——ffxエコシステムが持つバイナリタイプスタブに最も近いもの。

更新されたオープンクエスチョン

Section titled “更新されたオープンクエスチョン”

メインノートのクロージングセクションの4つの質問に加えて:

  • ターゲット検出rigor-ffiはffxターゲットをGemfile.lock / gemspecのランタイム依存から自動検出すべきか、明示的設定を要求すべきか、それとも両方か? 自動検出はユーザーフレンドリーだがバンドラー / gemspec解析に結合する。
  • デュアルターゲットgem — 実世界のgemがffi-gemとffxのコードパスを条件付きで出荷しているか? もしそうなら、ffx.unsupported-feature診断には宣言ごとの抑制メカニズムが必要(既存の# rigor:disableで十分なはず)。
  • rigor-fiddleとの境界Fiddleは別のStdlib FFIメカニズム(sqliteffxがアウトパラメータスロット読み取りに使用)。rigor-ffiのスコープ内に入れるか、それとも兄弟rigor-fiddleプラグインか? おそらく兄弟——異なるDSLサーフェス、異なるバインディング登録形状。

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.